diff --git a/.gitignore b/.gitignore index 8770f9b184b..b43d9f2bb5c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 1f445997bda..57694b2033f 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -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", diff --git a/docs/automations-all-hosts-design.md b/docs/automations-all-hosts-design.md new file mode 100644 index 00000000000..c186cc67c13 --- /dev/null +++ b/docs/automations-all-hosts-design.md @@ -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 | 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 ` of 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 `, `No automations across loaded hosts`, `No automations match your search`, and `Automations could not be loaded from `. 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. diff --git a/src/cli/automation-destination.ts b/src/cli/automation-destination.ts new file mode 100644 index 00000000000..6e67ee247b6 --- /dev/null +++ b/src/cli/automation-destination.ts @@ -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 { + 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 { + 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 { + 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 } } +} diff --git a/src/cli/automation-format.test.ts b/src/cli/automation-format.test.ts new file mode 100644 index 00000000000..18fcf0e3844 --- /dev/null +++ b/src/cli/automation-format.test.ts @@ -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 { + 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:') + }) +}) diff --git a/src/cli/automation-format.ts b/src/cli/automation-format.ts new file mode 100644 index 00000000000..3b7669b014b --- /dev/null +++ b/src/cli/automation-format.ts @@ -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') +} diff --git a/src/cli/automation-owner-conflict-recovery.test.ts b/src/cli/automation-owner-conflict-recovery.test.ts new file mode 100644 index 00000000000..738632945f8 --- /dev/null +++ b/src/cli/automation-owner-conflict-recovery.test.ts @@ -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 `') + }) +}) diff --git a/src/cli/automation-owner-conflict-recovery.ts b/src/cli/automation-owner-conflict-recovery.ts new file mode 100644 index 00000000000..8a900fcb17c --- /dev/null +++ b/src/cli/automation-owner-conflict-recovery.ts @@ -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 = { + [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 `.' + ], + [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 ` 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 +} diff --git a/src/cli/format.ts b/src/cli/format.ts index 2b6b67f05e9..8707ceef092 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -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' && diff --git a/src/cli/handlers/automation-destination-fencing.test.ts b/src/cli/handlers/automation-destination-fencing.test.ts new file mode 100644 index 00000000000..c2753702bdb --- /dev/null +++ b/src/cli/handlers/automation-destination-fencing.test.ts @@ -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; 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) => { + 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') + }) +}) diff --git a/src/cli/handlers/automation-owner-fencing.test.ts b/src/cli/handlers/automation-owner-fencing.test.ts new file mode 100644 index 00000000000..987c6ecfdec --- /dev/null +++ b/src/cli/handlers/automation-owner-fencing.test.ts @@ -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 } { + 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' }) + }) +}) diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index ab592504c00..d413b8a2a79 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -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 & { + 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[0]['client'], + id: string +): Promise { + 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 = { printResult(result, json, formatAutomationList) }, 'automations show': async ({ flags, client, json }) => { - const result = await client.call<{ automation: Automation }>('automation.show', { + const result = await client.call('automation.show', { id: getRequiredStringFlag(flags, 'id') }) printResult(result, json, formatAutomationShow) @@ -442,7 +467,8 @@ export const AUTOMATION_HANDLERS: Record = { 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 = { 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) }, diff --git a/src/cli/index-automation-identifiers.test.ts b/src/cli/index-automation-identifiers.test.ts index 488ca0f0ed1..f62f6765c84 100644 --- a/src/cli/index-automation-identifiers.test.ts +++ b/src/cli/index-automation-identifiers.test.ts @@ -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' }) }) diff --git a/src/cli/index-automation-session-reuse.test.ts b/src/cli/index-automation-session-reuse.test.ts index 3b6182b62d7..1ad2de5d95a 100644 --- a/src/cli/index-automation-session-reuse.test.ts +++ b/src/cli/index-automation-session-reuse.test.ts @@ -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 }) }) }) diff --git a/src/cli/index-automation-source-context.test.ts b/src/cli/index-automation-source-context.test.ts index 4d446291729..b74275a6373 100644 --- a/src/cli/index-automation-source-context.test.ts +++ b/src/cli/index-automation-source-context.test.ts @@ -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', diff --git a/src/cli/index-automation-target.test.ts b/src/cli/index-automation-target.test.ts index ca9e398cadd..e7ac9767c9a 100644 --- a/src/cli/index-automation-target.test.ts +++ b/src/cli/index-automation-target.test.ts @@ -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, diff --git a/src/cli/test-fixtures.ts b/src/cli/test-fixtures.ts index a8e7d8f91f2..b97018e2193 100644 --- a/src/cli/test-fixtures.ts +++ b/src/cli/test-fixtures.ts @@ -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) diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 01dd0dbabcf..008c2cf4c21 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -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') -} diff --git a/src/main/automations/automation-dispatch-host-fence.test.ts b/src/main/automations/automation-dispatch-host-fence.test.ts new file mode 100644 index 00000000000..cc8dbcf615d --- /dev/null +++ b/src/main/automations/automation-dispatch-host-fence.test.ts @@ -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 { + 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 +} { + 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 { + 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 }) + }) +}) diff --git a/src/main/automations/automation-ghost-ssh-tombstones.ts b/src/main/automations/automation-ghost-ssh-tombstones.ts new file mode 100644 index 00000000000..cdac62d92ce --- /dev/null +++ b/src/main/automations/automation-ghost-ssh-tombstones.ts @@ -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 { + const known = new Set(sshTargets.map((target) => target.id)) + const missing = new Set() + 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 + } +} diff --git a/src/main/automations/automation-orphan-enablement-restart.test.ts b/src/main/automations/automation-orphan-enablement-restart.test.ts new file mode 100644 index 00000000000..05776d0dd5a --- /dev/null +++ b/src/main/automations/automation-orphan-enablement-restart.test.ts @@ -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 { + 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> + +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 { + 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' + ) + }) +}) diff --git a/src/main/automations/automation-owner-fencing.test.ts b/src/main/automations/automation-owner-fencing.test.ts new file mode 100644 index 00000000000..4065d5c8d72 --- /dev/null +++ b/src/main/automations/automation-owner-fencing.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/main/automations/automation-owner-migration.test.ts b/src/main/automations/automation-owner-migration.test.ts new file mode 100644 index 00000000000..9bf6081b313 --- /dev/null +++ b/src/main/automations/automation-owner-migration.test.ts @@ -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 { + 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 { + return { + id: 'ssh-1', + label: 'Dev box', + host: 'dev.example.com', + port: 22, + username: 'tim', + ...overrides + } +} + +function makeRepo(overrides: Partial = {}): 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 { + 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) + }) +}) diff --git a/src/main/automations/automation-owner-migration.ts b/src/main/automations/automation-owner-migration.ts new file mode 100644 index 00000000000..5f7e388a771 --- /dev/null +++ b/src/main/automations/automation-owner-migration.ts @@ -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, + 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 +): 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 +): 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 +): SshTargetIdForGeneration { + const owners = new Map() + 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, + 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) + } +} diff --git a/src/main/automations/automation-run-terminal-surface.test.ts b/src/main/automations/automation-run-terminal-surface.test.ts new file mode 100644 index 00000000000..21f350093a1 --- /dev/null +++ b/src/main/automations/automation-run-terminal-surface.test.ts @@ -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() + }) +}) diff --git a/src/main/automations/automation-run-writer.test.ts b/src/main/automations/automation-run-writer.test.ts new file mode 100644 index 00000000000..a149f28ea38 --- /dev/null +++ b/src/main/automations/automation-run-writer.test.ts @@ -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) { + 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 + }) +}) diff --git a/src/main/automations/automation-run-writer.ts b/src/main/automations/automation-run-writer.ts new file mode 100644 index 00000000000..ef08c84de49 --- /dev/null +++ b/src/main/automations/automation-run-writer.ts @@ -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 + } + } +} diff --git a/src/main/automations/automation-skip-coalescing.test.ts b/src/main/automations/automation-skip-coalescing.test.ts new file mode 100644 index 00000000000..40e2fa5f0f5 --- /dev/null +++ b/src/main/automations/automation-skip-coalescing.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/src/main/automations/automation-ssh-readoption-migration.test.ts b/src/main/automations/automation-ssh-readoption-migration.test.ts new file mode 100644 index 00000000000..21333aa6801 --- /dev/null +++ b/src/main/automations/automation-ssh-readoption-migration.test.ts @@ -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 { + 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 + ) + }) +}) diff --git a/src/main/automations/automation-ssh-readoption-migration.ts b/src/main/automations/automation-ssh-readoption-migration.ts new file mode 100644 index 00000000000..29a561ef898 --- /dev/null +++ b/src/main/automations/automation-ssh-readoption-migration.ts @@ -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 + /** 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 +} diff --git a/src/main/automations/automation-update-host-retarget.test.ts b/src/main/automations/automation-update-host-retarget.test.ts new file mode 100644 index 00000000000..93aa4369184 --- /dev/null +++ b/src/main/automations/automation-update-host-retarget.test.ts @@ -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 { + 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> + +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 }) + }) +}) diff --git a/src/main/automations/automation-workspace-host-attribution.test.ts b/src/main/automations/automation-workspace-host-attribution.test.ts new file mode 100644 index 00000000000..546065b39e8 --- /dev/null +++ b/src/main/automations/automation-workspace-host-attribution.test.ts @@ -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 { + 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>, + start: () => void +): Promise { + 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() + }) +}) diff --git a/src/main/automations/dispatch-refusal.ts b/src/main/automations/dispatch-refusal.ts new file mode 100644 index 00000000000..7d7fdc4d140 --- /dev/null +++ b/src/main/automations/dispatch-refusal.ts @@ -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 + }) +} diff --git a/src/main/automations/external-automation-manager-cache.test.ts b/src/main/automations/external-automation-manager-cache.test.ts new file mode 100644 index 00000000000..dfa11857bff --- /dev/null +++ b/src/main/automations/external-automation-manager-cache.test.ts @@ -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) + }) +}) diff --git a/src/main/automations/external-automation-manager-cache.ts b/src/main/automations/external-automation-manager-cache.ts new file mode 100644 index 00000000000..5ade91d9e58 --- /dev/null +++ b/src/main/automations/external-automation-manager-cache.ts @@ -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() + 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, + options?: { refresh?: boolean } + ): Promise { + 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 + } +} diff --git a/src/main/automations/external-automation-owner-guard.test.ts b/src/main/automations/external-automation-owner-guard.test.ts new file mode 100644 index 00000000000..f018f8b0b22 --- /dev/null +++ b/src/main/automations/external-automation-owner-guard.test.ts @@ -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 { + 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 + ) + } + }) +}) diff --git a/src/main/automations/external-automation-owner-guard.ts b/src/main/automations/external-automation-owner-guard.ts new file mode 100644 index 00000000000..db69ff5a7a3 --- /dev/null +++ b/src/main/automations/external-automation-owner-guard.ts @@ -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) + } +} diff --git a/src/main/automations/external-automation-probe-scheduler.test.ts b/src/main/automations/external-automation-probe-scheduler.test.ts new file mode 100644 index 00000000000..ff56e9242cb --- /dev/null +++ b/src/main/automations/external-automation-probe-scheduler.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ExternalAutomationProbeCancelledError, + ExternalAutomationProbeScheduler, + isExternalAutomationProbeCancelled +} from './external-automation-probe-scheduler' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((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(), deferred(), deferred()] + 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() + 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() + 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) + }) +}) diff --git a/src/main/automations/external-automation-probe-scheduler.ts b/src/main/automations/external-automation-probe-scheduler.ts new file mode 100644 index 00000000000..ad5a33046f5 --- /dev/null +++ b/src/main/automations/external-automation-probe-scheduler.ts @@ -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 = { + /** `{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 +} + +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() + private readonly shared = new Map>() + 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(job: ExternalAutomationProbeJob): Promise { + const existing = this.shared.get(job.key) + if (existing) { + return existing as Promise + } + const controller = new AbortController() + const promise = new Promise((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 + } + + /** 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): 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() + } + } +} diff --git a/src/main/automations/external-manager-scoped.test.ts b/src/main/automations/external-manager-scoped.test.ts new file mode 100644 index 00000000000..dbf6d18dc85 --- /dev/null +++ b/src/main/automations/external-manager-scoped.test.ts @@ -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('fs') + return { ...actual, existsSync: existsSyncMock } +}) +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: vi.fn() })) + +function sshTarget(overrides: Partial = {}): 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 +} { + 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 + ) + 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) + 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) + }) +}) diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index 994d5cab560..f245521dad7 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -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) + + 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) + + 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', () => { diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index eb2f340322b..3d18fab8657 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -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 = { + hermes: 'Hermes', + openclaw: 'OpenClaw' +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -async function isCommandOnPath(command: string): Promise { - const finder = process.platform === 'win32' ? 'where' : 'which' - try { - await runLocalCommandLookup(finder, [command]) - return true - } catch { - return false - } -} - -function runLocalCommandLookup(command: string, args: string[]): Promise { +function runLocalProviderCommand( + command: string, + args: string[], + options: { timeoutMs: number; timeoutMessage: string; execTimeout?: boolean } +): Promise { return new Promise((resolve, reject) => { let child: ReturnType | null = null let settled = false @@ -65,57 +84,16 @@ function runLocalCommandLookup(command: string, args: string[]): Promise { 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 { - return new Promise((resolve, reject) => { - let child: ReturnType | 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 { + 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 { + 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 { if (!existsSync(HERMES_JOBS_FILE)) { return [] @@ -165,83 +168,84 @@ async function readLocalOpenClawJobs(): Promise { return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [] } -async function listLocalHermesManager(): Promise { - 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 { + 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 { - 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> { + 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 { - return listRemoteManager(target, 'hermes') -} - -async function listRemoteOpenClawManager(target: SshTarget): Promise { - return listRemoteManager(target, 'openclaw') + return error instanceof Error ? error.message : String(error) } async function listRemoteManager( target: SshTarget, provider: ExternalAutomationProvider ): Promise { - 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 { - 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 +> = { + 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 { 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 + listRuns: (request: ScopedExternalManagerRunsRequest) => Promise + create: (request: ScopedExternalManagerCreateRequest) => Promise + update: (request: ScopedExternalManagerUpdateRequest) => Promise + runAction: (request: ScopedExternalManagerActionRequest) => Promise +} + +/** + * 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) + } + } +} diff --git a/src/main/automations/headless-dispatch-runner.ts b/src/main/automations/headless-dispatch-runner.ts new file mode 100644 index 00000000000..8d4e3aed290 --- /dev/null +++ b/src/main/automations/headless-dispatch-runner.ts @@ -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 + dispatcher: HeadlessAutomationDispatcher + runs: AutomationRunWriter + runPrecheck: () => Promise + markDispatchResult: (result: AutomationDispatchResult) => Promise + watchRun: (run: AutomationRun) => void +} + +function describeDispatchError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export async function runHeadlessAutomationDispatch( + ctx: HeadlessAutomationDispatchContext +): Promise { + 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) + }) + } +} diff --git a/src/main/automations/refused-manual-run.test.ts b/src/main/automations/refused-manual-run.test.ts new file mode 100644 index 00000000000..211c0a6e55a --- /dev/null +++ b/src/main/automations/refused-manual-run.test.ts @@ -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 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 { + 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 => + (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() + }) +}) diff --git a/src/main/automations/refused-manual-run.ts b/src/main/automations/refused-manual-run.ts new file mode 100644 index 00000000000..f6a103408c2 --- /dev/null +++ b/src/main/automations/refused-manual-run.ts @@ -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 + recordRefusedRun: (automationId: string) => void +} + +export async function runAutomationNowFenced(input: { + fence: () => void + service: RefusableAutomationService + automationId: string +}): Promise { + 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) +} diff --git a/src/main/automations/retained-run-reconciliation.test.ts b/src/main/automations/retained-run-reconciliation.test.ts new file mode 100644 index 00000000000..aa3cc6d7183 --- /dev/null +++ b/src/main/automations/retained-run-reconciliation.test.ts @@ -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> + +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((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() + }) +}) diff --git a/src/main/automations/retained-run-reconciliation.ts b/src/main/automations/retained-run-reconciliation.ts new file mode 100644 index 00000000000..eeeb6d39b79 --- /dev/null +++ b/src/main/automations/retained-run-reconciliation.ts @@ -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() + private timer: ReturnType | 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 + } +} diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts new file mode 100644 index 00000000000..1f82c758879 --- /dev/null +++ b/src/main/automations/run-completion-watcher.test.ts @@ -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 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 => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1, + ...overrides +}) + +type TestStore = Awaited> + +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, + 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((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((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((_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() + }) +}) diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts new file mode 100644 index 00000000000..4ac125082fb --- /dev/null +++ b/src/main/automations/run-completion-watcher.ts @@ -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 +} + +/** 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 + private readonly watching = new Map() + private readonly reconciler: RetainedRunReconciler + private disposed = false + + constructor(opts: { + observer: AutomationRunTerminalObserver + readRun: (automationId: string, runId: string) => AutomationRun | null + markDispatchResult: (result: AutomationDispatchResult) => Promise + }) { + 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 { + 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 { + // 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 + }) + } +} diff --git a/src/main/automations/run-target-resolution.test.ts b/src/main/automations/run-target-resolution.test.ts index 73820e316c8..a1e62952da7 100644 --- a/src/main/automations/run-target-resolution.test.ts +++ b/src/main/automations/run-target-resolution.test.ts @@ -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 } diff --git a/src/main/automations/run-target-resolution.ts b/src/main/automations/run-target-resolution.ts index fcd96007ea0..1458dc2ad23 100644 --- a/src/main/automations/run-target-resolution.ts +++ b/src/main/automations/run-target-resolution.ts @@ -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 = { + [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) diff --git a/src/main/automations/runtime-terminal-run-observer.test.ts b/src/main/automations/runtime-terminal-run-observer.test.ts new file mode 100644 index 00000000000..a9b307c3298 --- /dev/null +++ b/src/main/automations/runtime-terminal-run-observer.test.ts @@ -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 +} + +function createFakeRuntime(initial: Partial) { + const pane: FakePane = { + lastAgentStatus: null, + paneTitle: null, + preview: '', + ...initial + } + const waiters = new Set() + 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) => 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 + }) +}) diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts new file mode 100644 index 00000000000..0dc6750e8de --- /dev/null +++ b/src/main/automations/runtime-terminal-run-observer.ts @@ -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 { + await new Promise((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 { + 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 { + 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 { + 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 { + 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 { + 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.' + ) + } + } + } + } + } +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index a9f7d336b78..5853e8570fb 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -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 - } - ).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() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 91b70ff6088..cb7c1f4f021 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -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 { @@ -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 { - 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 { - 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 - ): Promise { - 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) - }) - } - } } diff --git a/src/main/index.ts b/src/main/index.ts index ae8fed9981d..7fd86d94155 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/automation-change-publication.test.ts b/src/main/ipc/automation-change-publication.test.ts new file mode 100644 index 00000000000..6d10d1fd165 --- /dev/null +++ b/src/main/ipc/automation-change-publication.test.ts @@ -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 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 { + 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' } }]) + }) +}) diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts new file mode 100644 index 00000000000..2a50d123a0d --- /dev/null +++ b/src/main/ipc/automations-external-scope.test.ts @@ -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() + +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>(), + isDisposed: () => false +} +vi.mock('../ipc/ssh', () => ({ + getActiveMultiplexer: () => relay +})) + +function sshTarget(overrides: Partial = {}): 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 } = { + 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 { + 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 + 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 + await Promise.resolve() + + const probe = invoke('automations:listExternalManagerForOwner', { + owner: desktopSsh(), + provider: 'hermes' + }) as Promise + 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) + }) +}) diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index 7a523addec8..8c4bf46ae6e 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -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( + 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(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 = (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 => { - 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 => { - return listExternalAutomationRuns(input) - } + 'automations:listExternalManagerForOwner', + ( + _event, + request: ScopedExternalManagerListRequest + ): Promise => + 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 => + scopedExternal.listRuns(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:createExternalForOwner', + (_event, request: ScopedExternalManagerCreateRequest): Promise => + scopedExternal.create(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:updateExternalForOwner', + (_event, request: ScopedExternalManagerUpdateRequest): Promise => + scopedExternal.update(requireCapturedOwner(request)) + ) + ipcMain.handle( + 'automations:runExternalActionForOwner', + (_event, request: ScopedExternalManagerActionRequest): Promise => + 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 => service.runNow(args.id) + ( + _event, + args: { id: string; expectedOwner?: AutomationOwnerPrecondition } + ): Promise => + 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', diff --git a/src/main/persistence-ssh-readoption-automation-migration.test.ts b/src/main/persistence-ssh-readoption-automation-migration.test.ts new file mode 100644 index 00000000000..a459113d082 --- /dev/null +++ b/src/main/persistence-ssh-readoption-automation-migration.test.ts @@ -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 { + 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 { + 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 = {}) { + return { + repos: [sshRepo()], + sshTargets: [], + automations: [makeAutomation()], + removedSshTargetTombstones: [tombstone()], + ui: { ...getDefaultPersistedState(testState.dir).ui, automationHostFilter: undefined }, + ...overrides + } +} + +async function createStoreFromState(state: Record) { + 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) { + 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) + }) +}) diff --git a/src/main/persistence-workspace-pinned-automation-fence.test.ts b/src/main/persistence-workspace-pinned-automation-fence.test.ts new file mode 100644 index 00000000000..2b2dd4104f3 --- /dev/null +++ b/src/main/persistence-workspace-pinned-automation-fence.test.ts @@ -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 = {}) { + return { + repos: [localRepo()], + projectGroups: [projectGroup()], + folderWorkspaces: [pinnedFolderWorkspace()], + sshTargets: [prodTarget(generation)], + sshTargetGenerationCounter: generation, + automations: [], + ...overrides + } +} + +async function createStoreFromState(state: Record) { + 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) + }) +}) diff --git a/src/main/persistence-workspace-repin-automation-owner.test.ts b/src/main/persistence-workspace-repin-automation-owner.test.ts new file mode 100644 index 00000000000..8028507d188 --- /dev/null +++ b/src/main/persistence-workspace-repin-automation-owner.test.ts @@ -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 { + 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) { + 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 + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 14236869a29..eea0ac16920 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -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 ) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..947f4da0087 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 private readonly skillInstallOperations = new Map() private readonly skillInstallProgress = new Map() @@ -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 { + 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 { 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 { - if (!this.automationService) { + async runAutomationNow( + id: string, + expectedOwner?: AutomationOwnerPrecondition + ): Promise { + 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 { 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 { - 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 => { 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 { 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 { - 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 { 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>> 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 } } diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 190a5b88be5..de30a1f4547 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 0b083a64d61..f4b4768865b 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -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 = 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 { diff --git a/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts b/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts index 24da0c6b138..c5e089082bd 100644 --- a/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts +++ b/src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts @@ -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 } diff --git a/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts b/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts index 7c341efcfa8..1f7ad45a2fb 100644 --- a/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts +++ b/src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts new file mode 100644 index 00000000000..919a251d26d --- /dev/null +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -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( + (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 +}) diff --git a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts new file mode 100644 index 00000000000..4f07604738f --- /dev/null +++ b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts @@ -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) { + 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) + }) +}) diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index c5a8cf37440..eaa5bee5493 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -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 } + ) }) }) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index d4e74b719fe..cc4573d9a7a 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -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( - (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) }) }) ] diff --git a/src/main/runtime/rpc/methods/client-settings-schemas.ts b/src/main/runtime/rpc/methods/client-settings-schemas.ts new file mode 100644 index 00000000000..ad36e001058 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-settings-schemas.ts @@ -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(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({}) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index a7940828d88..79b923a55db 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(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(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(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index c62328ebc64..4161064be01 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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. diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 41691dc6ae9..2e0a4e4f4ab 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -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[] = [ diff --git a/src/main/ssh/removed-ssh-target-tombstone-retention.test.ts b/src/main/ssh/removed-ssh-target-tombstone-retention.test.ts new file mode 100644 index 00000000000..1d58297aa11 --- /dev/null +++ b/src/main/ssh/removed-ssh-target-tombstone-retention.test.ts @@ -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'])) + }) +}) diff --git a/src/main/ssh/removed-ssh-target-tombstone-retention.ts b/src/main/ssh/removed-ssh-target-tombstone-retention.ts new file mode 100644 index 00000000000..1044526c5c0 --- /dev/null +++ b/src/main/ssh/removed-ssh-target-tombstone-retention.ts @@ -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, + cap = MAX_REMOVED_SSH_TARGET_TOMBSTONES +): RemovedSshTargetTombstone[] { + if (tombstones.length <= cap) { + return [...tombstones] + } + const unreferencedToDrop = new Set() + 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 { + const ids = new Set() + 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 { + const ids = collectAutomationReferencedSshTargetIds(input.automations) + const filterTargetId = persistedAutomationHostFilterSshTargetId(input.automationHostFilter) + if (filterTargetId) { + ids.add(filterTargetId) + } + return ids +} diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 9acba4d18eb..b086a9c5e31 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -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. diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 97fc2e3b32e..08f78c77dd7 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -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 diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts index 18d3f075bf0..403688d2b31 100644 --- a/src/main/ssh/ssh-target-id-migration.ts +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -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, diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts index 2b066a0bdc4..e6d18a5927d 100644 --- a/src/main/ssh/ssh-target-readoption.test.ts +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -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', () => { diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts index 70d66078785..758e6c06e88 100644 --- a/src/main/ssh/ssh-target-readoption.ts +++ b/src/main/ssh/ssh-target-readoption.ts @@ -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 } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 48e2c82a547..f5a69677180 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d81d8c59f93..08511397a5c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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' diff --git a/src/preload/api/automation-api.ts b/src/preload/api/automation-api.ts index 90f13e4ce1e..37f1c08989f 100644 --- a/src/preload/api/automation-api.ts +++ b/src/preload/api/automation-api.ts @@ -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 - listRuns: (args?: { automationId?: string }) => Promise - listExternalManagers: () => Promise - listExternalRuns: (input: ExternalAutomationRunsInput) => Promise - createExternal: (input: ExternalAutomationCreateInput) => Promise - updateExternal: (input: ExternalAutomationUpdateInput) => Promise - runExternalAction: (input: ExternalAutomationActionInput) => Promise - create: (input: AutomationCreateInput) => Promise - update: (args: { id: string; updates: AutomationUpdateInput }) => Promise - delete: (args: { id: string }) => Promise - runNow: (args: { id: string }) => Promise + /** Host-scoped read of the desktop authority; the caller's captured SSH generation is verified. */ + listScoped: (params: { selector: AutomationListScopeSelector }) => Promise + listRuns: (args?: { + automationId?: string + expectedOwner?: AutomationOwnerPrecondition + }) => Promise + /** + * 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 + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ) => Promise + createExternalForOwner: (request: ScopedExternalManagerCreateRequest) => Promise + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest) => Promise + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest) => Promise + /** Probes outside the retained owners are cancelled; an empty list retains none. */ + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }) => Promise + create: ( + input: AutomationCreateInput, + options?: { destination?: AutomationDestination } + ) => Promise + update: (args: { + id: string + updates: AutomationUpdateInput + expectedOwner?: AutomationOwnerPrecondition + destination?: AutomationDestination + }) => Promise + delete: (args: { id: string; expectedOwner?: AutomationOwnerPrecondition }) => Promise + runNow: (args: { + id: string + expectedOwner?: AutomationOwnerPrecondition + }) => Promise runPrecheck: (args: { automationId: string runId: string @@ -34,4 +85,5 @@ export type AutomationsApi = { snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise rendererReady: () => Promise onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void + onChanged: (callback: (payload: AutomationsChangedPayload) => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d647..7ede56f5a2e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('automations:list'), - listRuns: (args?: { automationId?: string }): Promise => - ipcRenderer.invoke('automations:listRuns', args), - listExternalManagers: (): Promise => - ipcRenderer.invoke('automations:listExternalManagers'), - listExternalRuns: (input: ExternalAutomationRunsInput): Promise => - ipcRenderer.invoke('automations:listExternalRuns', input), - createExternal: (input: ExternalAutomationCreateInput): Promise => - ipcRenderer.invoke('automations:createExternal', input), - updateExternal: (input: ExternalAutomationUpdateInput): Promise => - ipcRenderer.invoke('automations:updateExternal', input), - runExternalAction: (input: ExternalAutomationActionInput): Promise => - ipcRenderer.invoke('automations:runExternalAction', input), - create: (input: AutomationCreateInput): Promise => - ipcRenderer.invoke('automations:create', input), - update: (args: { id: string; updates: AutomationUpdateInput }): Promise => - ipcRenderer.invoke('automations:update', args), - delete: (args: { id: string }): Promise => ipcRenderer.invoke('automations:delete', args), - runNow: (args: { id: string }): Promise => - ipcRenderer.invoke('automations:runNow', args), + listScoped: (params: { + selector: AutomationListScopeSelector + }): Promise => ipcRenderer.invoke('automations:list', params), + listRuns: (args?: { + automationId?: string + expectedOwner?: AutomationOwnerPrecondition + }): Promise => ipcRenderer.invoke('automations:listRuns', args), + listExternalManagerForOwner: ( + request: ScopedExternalManagerListRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalManagerForOwner', request), + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalRunsForOwner', request), + createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => + ipcRenderer.invoke('automations:createExternalForOwner', request), + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => + ipcRenderer.invoke('automations:updateExternalForOwner', request), + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => + ipcRenderer.invoke('automations:runExternalActionForOwner', request), + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => + ipcRenderer.invoke('automations:retainExternalScopes', request), + create: ( + input: AutomationCreateInput, + options?: { destination?: AutomationDestination } + ): Promise => ipcRenderer.invoke('automations:create', input, options), + update: (args: { + id: string + updates: AutomationUpdateInput + expectedOwner?: AutomationOwnerPrecondition + destination?: AutomationDestination + }): Promise => ipcRenderer.invoke('automations:update', args), + delete: (args: { id: string; expectedOwner?: AutomationOwnerPrecondition }): Promise => + ipcRenderer.invoke('automations:delete', args), + runNow: (args: { + id: string + expectedOwner?: AutomationOwnerPrecondition + }): Promise => 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) } }, diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx new file mode 100644 index 00000000000..44bae0fb08a --- /dev/null +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx @@ -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 ( + + +

+ {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.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationDetail.test.tsx b/src/renderer/src/components/automations/AutomationDetail.test.tsx new file mode 100644 index 00000000000..5842d4268d7 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationDetail.test.tsx @@ -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): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + + + ) + }) + 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() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index ef491c816c6..0fe23d0e7de 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -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 (
@@ -143,7 +145,7 @@ export function AutomationDetail({ {automation.enabled ? translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled') - : translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')} + : automationPausedLabel(automation)}

@@ -209,6 +211,16 @@ export function AutomationDetail({ + {enablementNotice ? ( +

+

{enablementNotice.reason}

+

{enablementNotice.recovery}

+
+ ) : null} + {automation.executionTargetType === 'ssh' ? (
{translate( diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 01a3f30a0d0..2cde7737590 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -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)} />
+ + + {createDestination && isCreateMode && !isHermesTarget ? ( +
+ +
+ ) : null} { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function entry(overrides: Partial = {}): 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({node}) + }) +} + +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() + expect(statusIds()).toEqual([]) + }) + + it('renders the two health axes as separate badges', () => { + render( + + ) + expect(statusIds('authority')).toEqual(['authority-stale-error']) + expect(statusIds('execution')).toEqual(['execution-disconnected']) + }) + + it('shows an execution failure while the authority is fresh', () => { + render() + expect(statusIds('authority')).toEqual([]) + expect(statusIds('execution')).toEqual(['execution-disconnected']) + }) + + it('shows an authority failure while the target is connected', () => { + render() + expect(statusIds('authority')).toEqual(['authority-unavailable']) + expect(statusIds('execution')).toEqual([]) + }) + + it('distinguishes an incompatible authority from a legacy-unscoped contract', () => { + render() + const incompatible = statusIds() + render() + 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() + expect(statusIds('query')).toEqual(['query-incompatible']) + expect(statusIds('authority')).toEqual([]) + }) + + it('names the removed host, not the server, for a ghost entry', () => { + render( + + ) + 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( + + ) + 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() + 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() + expect(statusIds()).toEqual(['authority-fresh', 'execution-connected']) + }) +}) + +describe('AutomationHostLabel', () => { + it('keeps the full name in the accessible name while the visible label truncates', () => { + render() + 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() + expect(container.querySelector('[data-host-stable-key]')?.getAttribute('aria-label')).toBe( + 'This computer · web-01' + ) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationHostBadges.tsx b/src/renderer/src/components/automations/AutomationHostBadges.tsx new file mode 100644 index 00000000000..7fe2aa608f4 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostBadges.tsx @@ -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['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 = { + 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 ( + + + {/* Truncation lives on the inner label, so the full name stays in the accessible name. */} + + + + + + {accessibleName} + + + ) +} + +type StatusBadgeProps = { + descriptor: AutomationHostStatusDescriptor + axis: 'authority' | 'execution' | 'query' +} + +function StatusBadge({ descriptor, axis }: StatusBadgeProps): React.JSX.Element { + const style = TONE_STYLE[descriptor.tone] + + return ( + + + + {descriptor.label} + + + + {descriptor.description} + + + ) +} + +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 ( + + {showAuthority ? : null} + {showExecution ? : null} + {query ? : null} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx new file mode 100644 index 00000000000..e4e6903c151 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx @@ -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 { + 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 { + 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( + + + + ) + }) +} + +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() + }) + } + + 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' + ) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx new file mode 100644 index 00000000000..2dcdbba8d12 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostFilterNotice.tsx @@ -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('') + // 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 ( +
+ {announced} +
+ ) +} + +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('') + 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. */} +
+ {announcedFallback} +
+ {message ? ( +
+ {message} + {resolution.entry ? : null} + {action && onRecover ? ( + + ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationHostPicker.test.tsx b/src/renderer/src/components/automations/AutomationHostPicker.test.tsx new file mode 100644 index 00000000000..370b1d01b89 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostPicker.test.tsx @@ -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 { + 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( + + + + ) + }) +} + +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('[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() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationHostPicker.tsx b/src/renderer/src/components/automations/AutomationHostPicker.tsx new file mode 100644 index 00000000000..22d45f81bf4 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostPicker.tsx @@ -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 ( + + ) + } + return ( + + ) +} diff --git a/src/renderer/src/components/automations/AutomationHostPickerCommand.tsx b/src/renderer/src/components/automations/AutomationHostPickerCommand.tsx new file mode 100644 index 00000000000..fe9da55a6fe --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostPickerCommand.tsx @@ -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 ( + + + + + + + + + + {translate('auto.components.automations.hostPicker.noHostMatch', 'No hosts match')} + + + choose({ kind: 'all' })} + className="text-xs" + > + + {translate('auto.components.automations.hostPicker.allHosts', 'All hosts')} + + + {groups.map((group) => ( + + {group.entries.map((entry) => ( + choose(automationHostFilterForEntry(entry))} + className="gap-2 text-xs" + data-host-stable-key={entry.stableKey} + > + + + + + ))} + + ))} + + + + + ) +} diff --git a/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx b/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx new file mode 100644 index 00000000000..d192465bb7e --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostPickerSelect.test.tsx @@ -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( + + + + ) + }) +} + +/** 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('[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' } } + }) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx b/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx new file mode 100644 index 00000000000..3c320552c9b --- /dev/null +++ b/src/renderer/src/components/automations/AutomationHostPickerSelect.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +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' + +/** Unsearchable host picker for eight or fewer entries (design doc, UX section). */ + +export type AutomationHostPickerSelectProps = { + entries: readonly AutomationHostCatalogEntry[] + selectedStableKey: string | null + /** What the trigger must say when no item matches the selection. */ + triggerLabel: string + pickerLabel: string + disabled?: boolean + onSelect: (filter: AutomationHostFilter) => void + className?: string +} + +export function AutomationHostPickerSelect({ + entries, + selectedStableKey, + triggerLabel, + pickerLabel, + disabled, + onSelect, + className +}: AutomationHostPickerSelectProps): React.JSX.Element { + const groups = groupAutomationHostEntriesByAuthority(entries) + const byStableKey = new Map(entries.map((entry) => [entry.stableKey, entry])) + + const handleChange = (value: string): void => { + if (value === ALL_HOSTS_OPTION_VALUE) { + onSelect({ kind: 'all' }) + return + } + const entry = byStableKey.get(value) + if (entry) { + onSelect(automationHostFilterForEntry(entry)) + } + } + + return ( + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx new file mode 100644 index 00000000000..da200352b7c --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListEmptyView.test.tsx @@ -0,0 +1,117 @@ +// @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 { + AutomationListEmptyView, + type AutomationListEmptyViewProps +} from './AutomationListEmptyView' +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 entry(overrides: Partial = {}): 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 hostResolution(host = entry()): AutomationHostFilterResolution { + return { + effective: { kind: 'host', host: host.stableRef }, + entry: host, + status: 'ready', + announceFallback: false + } +} + +function render(overrides: Partial = {}): void { + act(() => { + root.render( + + ) + }) +} + +describe('AutomationListEmptyView', () => { + it('renders nothing while rows exist', () => { + render({ visibleRowCount: 4 }) + expect(container.querySelector('[data-empty-state]')).toBeNull() + }) + + it('shows the single-host empty copy', () => { + render() + const state = container.querySelector('[data-empty-state="host-empty"]') + expect(state?.textContent).toContain('No automations on web-01') + }) + + it('never renders an empty claim for a disconnected host', () => { + render({ resolution: hostResolution(entry({ executionHealth: 'disconnected' })) }) + expect(container.querySelector('[data-empty-state="host-empty"]')).toBeNull() + expect(container.textContent).not.toContain('No automations') + expect(container.querySelector('[data-empty-state="host-not-connected"]')).not.toBeNull() + }) + + it('states the external-manager scope limit for a scope-limited host', () => { + render({ externalManagersListed: false }) + expect(container.querySelector('[data-scope-note="external-managers"]')?.textContent).toBe( + 'External automation managers are not listed for this host in this release.' + ) + }) + + it('omits the scope note when managers are listed', () => { + render() + expect(container.querySelector('[data-scope-note="external-managers"]')).toBeNull() + }) + + it('invokes the recovery action the failure calls for', () => { + const onRecover = vi.fn() + render({ + resolution: hostResolution(entry({ authorityHealth: 'stale-error' })), + onRecover + }) + const button = container.querySelector('button') + expect(button?.textContent).toBe('Retry') + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onRecover).toHaveBeenCalledWith('retry') + }) + + it('omits the recovery button when the caller offers no handler', () => { + render({ resolution: hostResolution(entry({ authorityHealth: 'stale-error' })) }) + expect(container.querySelector('button')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListEmptyView.tsx b/src/renderer/src/components/automations/AutomationListEmptyView.tsx new file mode 100644 index 00000000000..b6950356c03 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListEmptyView.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { + resolveAutomationListEmptyState, + type AutomationListEmptyStateInput +} from './automation-list-empty-state' +import { + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' + +/** Renders whichever empty/partial/failure state the list is in; nothing when rows exist. */ + +export type AutomationListEmptyViewProps = AutomationListEmptyStateInput & { + onRecover?: (action: AutomationHostRecoveryAction) => void + className?: string +} + +export function AutomationListEmptyView({ + onRecover, + className, + ...input +}: AutomationListEmptyViewProps): React.JSX.Element | null { + const state = resolveAutomationListEmptyState(input) + const recovery = state.recovery + if (state.kind === 'rows') { + return null + } + + return ( +
+

{state.title}

+ {state.detail ?

{state.detail}

: null} + {state.scopeNote ? ( +

+ {state.scopeNote} +

+ ) : null} + {recovery && onRecover ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index b22a648b752..4a50bd3195f 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -23,6 +23,7 @@ import type { } from '../../../../shared/automations-types' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { formatExternalDate, getExternalProviderLabel, @@ -57,9 +58,14 @@ export function AutomationListExternalRows({ onRequestAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope + ) => void + onEdit: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope ) => void - onEdit: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void }): React.JSX.Element { return ( <> @@ -148,7 +154,7 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'run')} + onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)} > @@ -162,7 +168,7 @@ export function AutomationListExternalRows({ {entry.manager.provider === 'hermes' ? ( onEdit(entry.manager, entry.job)} + onSelect={() => onEdit(entry.manager, entry.job, entry.scope)} > {translate( @@ -177,7 +183,8 @@ export function AutomationListExternalRows({ onRequestAction( entry.manager, entry.job, - entry.job.enabled ? 'pause' : 'resume' + entry.job.enabled ? 'pause' : 'resume', + entry.scope ) } > @@ -200,7 +207,9 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'delete')} + onSelect={() => + onRequestAction(entry.manager, entry.job, 'delete', entry.scope) + } > {translate( @@ -215,7 +224,7 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'run')} + onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)} > @@ -226,7 +235,7 @@ export function AutomationListExternalRows({ {entry.manager.provider === 'hermes' ? ( onEdit(entry.manager, entry.job)} + onSelect={() => onEdit(entry.manager, entry.job, entry.scope)} > {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} @@ -235,7 +244,12 @@ export function AutomationListExternalRows({ - onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume') + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume', + entry.scope + ) } > {entry.job.enabled ? : } @@ -247,7 +261,7 @@ export function AutomationListExternalRows({ onRequestAction(entry.manager, entry.job, 'delete')} + onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)} > {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} diff --git a/src/renderer/src/components/automations/AutomationListHostGroups.test.tsx b/src/renderer/src/components/automations/AutomationListHostGroups.test.tsx new file mode 100644 index 00000000000..2288fa79a9d --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListHostGroups.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment happy-dom + +/** + * The All-hosts groups render a host that answered with nothing next to a host + * nobody could ask. These tests pin the difference, because a shared "no rows" + * sentence made the second read as the first — a claim about storage that no + * request ever backed. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AutomationListHostGroups } from './AutomationListHostGroups' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { filterAutomationHostGroups } from './automation-host-list-rows' +import type { AutomationListRow } from './automation-list-row-identity' +import { makeAutomation, makeAutomationListRow } from './automations-page-fixtures' + +vi.mock('./AutomationListLocalRows', () => ({ + AutomationListLocalRows: ({ rows }: { rows: readonly AutomationListRow[] }) => ( +
{rows.map((row) => row.automation.name).join(',')}
+ ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 } + }, + stableKey: 'host:desktop:ssh:t1', + label: 'web-01', + authorityLabel: 'This computer', + kind: 'ssh', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +const roots: Root[] = [] + +async function render(options: { + entry: AutomationHostCatalogEntry + rows?: readonly AutomationListRow[] + visibleRowKeys?: ReadonlySet + searchActive?: boolean +}): Promise { + const rows = options.rows ?? [] + const groups = filterAutomationHostGroups( + [ + { + authorityKey: 'authority:desktop', + authorityLabel: 'This computer', + hosts: [{ entry: options.entry, rows }] + } + ], + options.visibleRowKeys ?? new Set(rows.map((row) => row.key)) + ) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + null} + automationSourceHostAvailabilityByRowKey={new Map()} + onSelect={vi.fn()} + onRunNow={vi.fn()} + onEdit={vi.fn()} + onToggle={vi.fn()} + onDelete={vi.fn()} + onRecover={vi.fn()} + /> + ) + }) + return container +} + +function emptyState(container: HTMLDivElement): { kind: string | null; text: string } { + const node = container.querySelector('[data-empty-state]') + return { kind: node?.getAttribute('data-empty-state') ?? null, text: node?.textContent ?? '' } +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('AutomationListHostGroups empty groups', () => { + it('does not claim an unreachable host holds no automations', async () => { + const container = await render({ entry: entry({ authorityHealth: 'unavailable' }) }) + + expect(emptyState(container).kind).toBe('host-unavailable') + expect(container.textContent).not.toContain('No automations listed for this host') + expect(emptyState(container).text).toContain('could not be loaded from web-01') + }) + + it('does not claim a disconnected host holds no automations', async () => { + const container = await render({ entry: entry({ executionHealth: 'disconnected' }) }) + + expect(emptyState(container).kind).toBe('host-not-connected') + expect(emptyState(container).text).not.toContain('No automations') + }) + + it('does not claim an unhydrated host holds no automations', async () => { + const container = await render({ entry: entry({ catalogState: 'unhydrated' }) }) + + expect(emptyState(container).kind).toBe('host-loading') + expect(emptyState(container).text).not.toContain('No automations') + }) + + it('blames the query when a search hid every row of a healthy host', async () => { + const container = await render({ + entry: entry(), + rows: [makeAutomationListRow({ automation: makeAutomation({ name: 'Nightly' }) })], + visibleRowKeys: new Set(), + searchActive: true + }) + + expect(emptyState(container).kind).toBe('search-no-match') + expect(emptyState(container).text).toBe('No automations match your search') + }) + + it('says a healthy connected host is empty, naming the host', async () => { + const container = await render({ entry: entry() }) + + expect(emptyState(container).kind).toBe('host-empty') + expect(emptyState(container).text).toContain('No automations on web-01') + }) + + it('states the external-manager scope limit for a runtime-owned host', async () => { + const container = await render({ + entry: entry({ + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'self' } + }, + owner: null + }) + }) + + expect(container.querySelector('[data-scope-note="external-managers"]')?.textContent).toContain( + 'External automation managers are not listed' + ) + }) + + it('renders the rows a host did report', async () => { + const container = await render({ + entry: entry(), + rows: [makeAutomationListRow({ automation: makeAutomation({ name: 'Nightly' }) })] + }) + + expect(container.querySelector('[data-testid="rows"]')?.textContent).toBe('Nightly') + expect(emptyState(container).kind).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListHostGroups.tsx b/src/renderer/src/components/automations/AutomationListHostGroups.tsx new file mode 100644 index 00000000000..ba751d5aec2 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListHostGroups.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges' +import { + AutomationListLocalRows, + type AutomationListLocalRowsProps +} from './AutomationListLocalRows' +import type { AutomationHostVisibleGroup } from './automation-host-list-rows' +import { + resolveAutomationHostGroupEmptyState, + type AutomationListEmptyState +} from './automation-list-empty-state' +import { externalManagersListedForEntry } from './external-automation-scope-gating' +import { + automationHostRecoveryActions, + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +/** + * The All-hosts list: rows grouped by authority, each host anchoring its own + * status row. + * + * The status sits on the host header rather than on the rows because it is a + * fact about the host, and because a host with no rows still has to say why — + * an unreachable host that rendered as an empty gap would read as "nothing + * scheduled here", which is the one thing we have not established. + */ + +export type AutomationListHostGroupsProps = Omit & { + groups: readonly AutomationHostVisibleGroup[] + searchActive: boolean + onRecover: (action: AutomationHostRecoveryAction, entry: AutomationHostCatalogEntry) => void +} + +function hostRecoveryAction( + entry: AutomationHostCatalogEntry +): AutomationHostRecoveryAction | null { + const recovery = automationHostRecoveryActions(entry) + return recovery.authority ?? recovery.execution +} + +function AutomationHostGroupState({ + state, + recovery, + onRecover +}: { + state: AutomationListEmptyState + recovery: AutomationHostRecoveryAction | null + onRecover: (action: AutomationHostRecoveryAction) => void +}): React.JSX.Element { + return ( +
+
{state.title}
+ {state.detail ?
{state.detail}
: null} + {state.scopeNote ? ( +
+ {state.scopeNote} +
+ ) : null} + {recovery ? ( + + ) : null} +
+ ) +} + +export function AutomationListHostGroups({ + groups, + searchActive, + onRecover, + ...rowProps +}: AutomationListHostGroupsProps): React.JSX.Element { + return ( + <> + {groups.map((group) => ( +
+
+ {group.authorityLabel} +
+ {group.hosts.map(({ entry, rows, hostRowCount }) => { + const action = hostRecoveryAction(entry) + const state = resolveAutomationHostGroupEmptyState({ + entry, + hostRowCount, + visibleRowCount: rows.length, + searchActive, + externalManagersListed: externalManagersListedForEntry(entry) + }) + return ( +
+
+ + + {action ? ( + + ) : null} +
+ {state.kind === 'rows' ? ( + + ) : ( + onRecover(recovery, entry)} + /> + )} +
+ ) + })} +
+ ))} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx index 221e0e45142..dee3ff4dc1f 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.test.tsx @@ -10,6 +10,8 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' +import type { AutomationListRow } from './automation-list-row-identity' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { AutomationListLocalRows } from './AutomationListLocalRows' import { AutomationListExternalRows } from './AutomationListExternalRows' import { indexLatestAutomationRuns } from './automation-list-last-run' @@ -112,14 +114,20 @@ function makeRun(overrides: Partial = {}): AutomationRun { } function renderLocalRows(handlers: { - onSelect: (automationId: string) => void - onDelete?: (automation: Automation) => void + onSelect: (rowKey: string) => void + onDelete?: (row: AutomationListRow) => void runs?: AutomationRun[] }) { + const row: AutomationListRow = { + key: 'row|local|automation-1', + automation: makeAutomation(), + hostLabel: 'Local', + usageSummary: null + } return render( null} + automationSourceHostAvailabilityByRowKey={new Map()} hostLabelById={new Map()} onSelect={handlers.onSelect} onRunNow={vi.fn()} @@ -145,14 +153,19 @@ function renderExternalRows(handlers: { onRequestAction?: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: 'run' | 'pause' | 'resume' | 'delete' + action: 'run' | 'pause' | 'resume' | 'delete', + scope: ExternalAutomationScope ) => void }) { const manager = makeExternalManager() const job = makeExternalJob() + const scope: ExternalAutomationScope = { + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + provider: 'hermes' + } return render( { await user.click(row as Element) expect(onSelect).toHaveBeenCalledTimes(1) - expect(onSelect).toHaveBeenCalledWith('automation-1') + expect(onSelect).toHaveBeenCalledWith('row|local|automation-1') }) it('opens the actions menu on keyboard activation without selecting the row', async () => { diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 7e4e71d244d..197f15db915 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -18,7 +18,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import type { AutomationRun } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import { formatUiAutomationSchedule } from './automation-schedule-label' import { @@ -32,41 +32,29 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' import { getLocalAutomationLastRunSnapshot } from './automation-list-last-run' import { AutomationListLastRunCell } from './AutomationListLastRunCell' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' import { getAutomationTargetAvailability } from './automation-target-availability' import { getAgentLabel } from './automation-draft-model' -import { formatAutomationCost } from './automation-usage-model' +import type { AutomationListRow } from './automation-list-row-identity' +import { + formatAutomationCost, + formatAutomationTokens, + type AutomationUsageSummary +} from './automation-usage-model' +import { automationPausedLabel } from './automation-enablement-notice' import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' import { LIST_TABLE_ROW_CLASS, LIST_TABLE_ROW_SELECTED_CLASS } from '@/lib/list-table-layout' import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction' import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' -export function AutomationListLocalRows({ - automations, - selectedId, - isSelectedLocal, - lastRunByAutomationId, - relativeNow, - repoMap, - worktreeMap, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - automationSourceHostAvailabilityById, - hostLabelById, - onSelect, - onRunNow, - onEdit, - onToggle, - onDelete -}: { - automations: readonly Automation[] - selectedId: string | null | undefined +export type AutomationListLocalRowsProps = { + rows: readonly AutomationListRow[] + selectedRowKey: string | null | undefined isSelectedLocal: boolean lastRunByAutomationId: ReadonlyMap relativeNow: number @@ -78,18 +66,69 @@ export function AutomationListLocalRows({ string, { status: RuntimeStatus | null; checkedAt: number } > - automationHostTarget: AutomationHostTarget | null - automationSourceHostAvailabilityById: ReadonlyMap - hostLabelById: ReadonlyMap - onSelect: (automationId: string) => void - onRunNow: (automation: Automation) => void - onEdit: (automation: Automation) => void - onToggle: (automation: Automation) => void - onDelete: (automation: Automation) => void -}): React.JSX.Element { + hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null + automationSourceHostAvailabilityByRowKey: ReadonlyMap + hostLabelById?: ReadonlyMap + isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean + onSelect: (rowKey: string) => void + onRunNow: (row: AutomationListRow) => void + onEdit: (row: AutomationListRow) => void + onToggle: (row: AutomationListRow) => void + onDelete: (row: AutomationListRow) => void +} + +function automationUsageText(summary: AutomationUsageSummary | undefined): string { + if (!summary || summary.unavailableRuns > 0) { + return summary?.knownRuns + ? usageAmountText(summary) + : translate( + 'auto.components.automations.AutomationsPage.usageUnavailable', + 'Usage unavailable' + ) + } + return summary.knownRuns > 0 + ? usageAmountText(summary) + : translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet') +} + +function usageAmountText(summary: AutomationUsageSummary): string { + return translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(summary.estimatedCostUsd), + tokens: formatAutomationTokens(summary.totalTokens) + } + ) +} + +export function AutomationListLocalRows({ + rows, + selectedRowKey, + isSelectedLocal, + lastRunByAutomationId, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById = new Map(), + isActionEnabled, + onSelect, + onRunNow, + onEdit, + onToggle, + onDelete +}: AutomationListLocalRowsProps): React.JSX.Element { + const allows = (row: AutomationListRow, action: AutomationRowAction): boolean => + isActionEnabled?.(row, action) ?? true return ( <> - {automations.map((automation) => { + {rows.map((row) => { + const { automation } = row const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) const automationWorktree = automation.workspaceId ? worktreeMap.get(automation.workspaceId) @@ -101,8 +140,8 @@ export function AutomationListLocalRows({ projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + automationHostTarget: hostTargetFor(row), + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key) }) const projectLabel = automationRepo?.displayName ?? @@ -110,40 +149,41 @@ export function AutomationListLocalRows({ const scheduleLabel = formatUiAutomationSchedule(automation.rrule) const nextRunLabel = automation.enabled ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const isSelected = isSelectedLocal && selectedId === automation.id + : automationPausedLabel(automation) + const isSelected = isSelectedLocal && selectedRowKey === row.key const agentLabel = getAgentLabel(automation.agentId) const hostId = automation.runContext?.hostId ?? (automationRepo ? getRepoExecutionHostId(automationRepo) : null) - const hostLabel = hostId - ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) - : getLocalExecutionHostLabel() + const hostLabel = + row.hostLabel || + (hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : getLocalExecutionHostLabel()) + const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}` + const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run') const lastRun = lastRunByAutomationId.get(automation.id) const lastRunSnapshot = getLocalAutomationLastRunSnapshot(automation, lastRun) - const lastRunCost = - lastRun?.usage?.status === 'known' - ? formatAutomationCost(lastRun.usage.estimatedCostUsd) - : formatAutomationCost(null) - const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${lastRunCost}` const actionItems = ( <> onRunNow(automation)} + onSelect={() => onRunNow(row)} /> } label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - onSelect={() => onEdit(automation)} + onSelect={() => onEdit(row)} /> : } @@ -152,24 +192,26 @@ export function AutomationListLocalRows({ ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') } - onSelect={() => onToggle(automation)} + onSelect={() => onToggle(row)} /> } label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} variant="destructive" - onSelect={() => onDelete(automation)} + onSelect={() => onDelete(row)} /> ) return ( - +
{ // Why: Radix portals menus out of the row DOM, but React still @@ -177,14 +219,14 @@ export function AutomationListLocalRows({ if (isPortaledRowMenuClick(event)) { return } - onSelect(automation.id) + onSelect(row.key) }} onKeyDown={(event) => { if (!isRowActivationKey(event)) { return } event.preventDefault() - onSelect(automation.id) + onSelect(row.key) }} className={cn( AUTOMATIONS_TABLE_GRID_CLASS, @@ -235,10 +277,10 @@ export function AutomationListLocalRows({ { - if (automationRunAvailability.canRunNow) { - onRunNow(automation) + if (canRunNow) { + onRunNow(row) } }} > @@ -252,11 +294,14 @@ export function AutomationListLocalRows({ : automationRunAvailability.message} - onEdit(automation)}> + onEdit(row)}> {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - onToggle(automation)}> + onToggle(row)} + > {automation.enabled ? ( ) : ( @@ -273,7 +318,11 @@ export function AutomationListLocalRows({ )} - onDelete(automation)}> + onDelete(row)} + > {translate( 'auto.components.automations.AutomationsPage.15e0bfb13b', @@ -319,18 +368,20 @@ function MenuRunItem({ } function MenuItem({ + disabled, icon, label, onSelect, variant }: { + disabled?: boolean icon: React.ReactNode label: string onSelect: () => void variant?: 'destructive' }): React.JSX.Element { return ( - + {icon} {label} diff --git a/src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx b/src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx new file mode 100644 index 00000000000..deea6844217 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationOwnerConflictNotice.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + recoveryActionLabel, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' +import type { AutomationOwnerConflict } from './automation-owner-action-runner' +import type { AutomationActionBlock } from './automation-captured-owner' + +/** + * An owner conflict shown where the user was working, not as a toast: the + * request performed nothing, so the message has to survive long enough to be + * read and acted on. It offers a recovery action only when one exists — a host + * that was deregistered gets a plain explanation rather than a button that + * cannot help. + */ + +type AutomationOwnerNotice = { + message: string + recovery: AutomationHostRecoveryAction | null +} + +export function ownerConflictNotice(conflict: AutomationOwnerConflict): AutomationOwnerNotice { + return { message: conflict.message, recovery: conflict.recovery } +} + +export function actionBlockNotice(block: AutomationActionBlock): AutomationOwnerNotice { + return { message: block.message, recovery: block.recovery } +} + +type AutomationOwnerConflictNoticeProps = { + notice: AutomationOwnerNotice | null + onRecover?: (action: AutomationHostRecoveryAction) => void + onDismiss?: () => void + className?: string +} + +export function AutomationOwnerConflictNotice({ + notice, + onRecover, + onDismiss, + className +}: AutomationOwnerConflictNoticeProps): React.JSX.Element | null { + if (!notice) { + return null + } + const recovery = notice.recovery + return ( +
+ {notice.message} + {recovery && onRecover ? ( + + ) : null} + {onDismiss ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx new file mode 100644 index 00000000000..7742f74b26a --- /dev/null +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +/** + * Coalescing folds consecutive identical refusals into one row, which is a large + * improvement over the hundred rows it replaced — but the surviving row carries + * the *first* occurrence's timestamp. Without the fold count and the latest + * occurrence, a failure that is still happening every hour reads as one thing + * that happened once, days ago. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import { AutomationRunHistory } from './AutomationRunHistory' +import { makeRun } from './automations-page-fixtures' + +const roots: Root[] = [] + +const FIRST = Date.UTC(2026, 7, 9, 14, 0) +const LATEST = Date.UTC(2026, 7, 11, 9, 0) + +async function render(overrides: Partial): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + ) + }) + return container +} + +async function renderFailure( + onRecoverHistory: (action: 'retry' | 'reconnect' | 'update-server') => void +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + ) + }) + return container +} + +function occurrences(container: HTMLDivElement): string | null { + return container.querySelector('[data-testid="automation-run-occurrences"]')?.textContent ?? null +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('AutomationRunHistory occurrences', () => { + it('says how many times a folded row stands for and when it last happened', async () => { + const container = await render({ occurrenceCount: 12, lastOccurrenceAt: LATEST }) + + expect(occurrences(container)).toContain('12 times') + // The recency is the part that answers "is this over?" — the row's own date + // is the first occurrence and cannot. + expect(occurrences(container)).toContain('most recently') + expect(occurrences(container)).not.toContain(String(LATEST)) + }) + + it('adds nothing to a row that stands for a single occurrence', async () => { + const container = await render({}) + + expect(occurrences(container)).toBeNull() + }) + + it('adds nothing when a host reported a count of one', async () => { + const container = await render({ occurrenceCount: 1 }) + + expect(occurrences(container)).toBeNull() + }) + + it('still reports the count when an older host folded without a timestamp', async () => { + const container = await render({ occurrenceCount: 4 }) + + expect(occurrences(container)).toContain('4 times') + expect(occurrences(container)).not.toContain('most recently') + }) +}) + +describe('AutomationRunHistory unanswered history', () => { + it('states the failure instead of reporting zero runs', async () => { + const container = await renderFailure(vi.fn()) + + expect(container.textContent).not.toContain('No runs yet.') + // "0 runs" is a count of something nobody managed to read. + expect(container.textContent).not.toContain('0 runs') + expect(container.textContent).toContain('Run history could not be loaded') + expect(container.textContent).toContain('web-01 is not connected') + }) + + it('offers the recovery the failure named rather than a dead end', async () => { + const onRecoverHistory = vi.fn() + const container = await renderFailure(onRecoverHistory) + const button = container.querySelector('[data-testid="automation-owner-conflict"] button') + + expect(button?.textContent).toBe('Reconnect') + await act(async () => { + ;(button as HTMLButtonElement).click() + }) + + expect(onRecoverHistory).toHaveBeenCalledWith('reconnect') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index 323accb7259..cd2ece0d7dc 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -13,13 +13,20 @@ import { formatAutomationTokens, getAutomationUsageStatusLabel } from './automation-usage-model' +import { automationRunOccurrenceLabel } from './automation-run-occurrences' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' +import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice' +import type { AutomationActionNotice } from './automation-row-action-dispatch' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' import { translate } from '@/i18n/i18n' type AutomationRunHistoryProps = { runs: AutomationRun[] automationId: string worktreeMap: ReadonlyMap + /** Set when the history read failed; the runs below are unknown, not zero. */ + notice?: AutomationActionNotice | null + onRecoverHistory?: (action: AutomationHostRecoveryAction) => void onOpenRun: (run: AutomationRun) => void } @@ -27,6 +34,8 @@ export function AutomationRunHistory({ runs, automationId, worktreeMap, + notice, + onRecoverHistory, onOpenRun }: AutomationRunHistoryProps): React.JSX.Element { const [selectedRunState, setSelectedRunState] = useState<{ @@ -51,7 +60,8 @@ export function AutomationRunHistory({
{translate('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')}
-
{runCountLabel}
+ {/* A failed read knows no counts; "0 runs" would answer a question nobody asked the host. */} + {notice ? null :
{runCountLabel}
}
@@ -79,6 +89,7 @@ export function AutomationRunHistory({ worktree: runWorktree }) const usageLabel = getAutomationUsageStatusLabel(run.usage) + const occurrenceLabel = automationRunOccurrenceLabel(run) return ( ) })} - {runs.length === 0 ? ( + {notice ? ( +
+

+ {translate( + 'auto.components.automations.AutomationRunHistory.historyUnavailable', + 'Run history could not be loaded for this automation.' + )} +

+ +
+ ) : runs.length === 0 ? (
{translate( 'auto.components.automations.AutomationRunHistory.402651bfb6', diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 4de2a9ed795..f596c21c36b 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -20,6 +20,7 @@ import { AutomationRunHistory } from './AutomationRunHistory' import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { formatExternalDate, getExternalProviderLabel, @@ -33,6 +34,8 @@ import { getAutomationRunStatusVariant } from './automation-page-parts' import { getAutomationRunContent } from './automation-run-content' +import type { AutomationActionNotice } from './automation-row-action-dispatch' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' import type { AutomationTargetAvailability } from './automation-target-availability' import type { AutomationRunViewState } from './automation-run-view-state' import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' @@ -45,6 +48,8 @@ type AutomationsDetailPaneProps = { selectedExternalRunPage: SelectedExternalRunPage | null selectedAutomationRunPage: AutomationRun | null selectedRuns: AutomationRun[] + /** Set when the selected automation's history read failed; its runs are unknown. */ + selectedRunsNotice: AutomationActionNotice | null activePaneTab: AutomationPaneTab relativeNow: number externalActionKey: string | null @@ -65,14 +70,19 @@ type AutomationsDetailPaneProps = { requestExternalAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ) => void openExternalRunPage: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, run: ExternalAutomationRun ) => void - openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + openEditExternalDialog: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void runNow: (automation: Automation) => void openEditDialog: (automation: Automation) => void toggleAutomation: (automation: Automation) => void @@ -81,6 +91,7 @@ type AutomationsDetailPaneProps = { openRunWorkspace: (run: AutomationRun) => void openAutomationRunPage: (run: AutomationRun) => void onBackToList: () => void + recoverSelectedRuns: (action: AutomationHostRecoveryAction) => void } export function AutomationsDetailPane({ @@ -89,6 +100,7 @@ export function AutomationsDetailPane({ selectedExternalRunPage, selectedAutomationRunPage, selectedRuns, + selectedRunsNotice, activePaneTab, relativeNow, externalActionKey, @@ -116,7 +128,8 @@ export function AutomationsDetailPane({ rerunAutomationRun, openRunWorkspace, openAutomationRunPage, - onBackToList + onBackToList, + recoverSelectedRuns }: AutomationsDetailPaneProps): React.JSX.Element { return (
@@ -150,8 +163,13 @@ export function AutomationsDetailPane({ ) : ( diff --git a/src/renderer/src/components/automations/AutomationsListPanel.test.tsx b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx new file mode 100644 index 00000000000..b3428780ddc --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsListPanel.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +/** + * The list panel's persistent chrome: the picker and the search field stay put + * across loading, empty, no-match, and failure states, so a refresh that briefly + * returns no rows cannot take the query and the caret with it. + */ + +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 { AutomationsListPanel } from './AutomationsListPanel' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import { makeAutomationListRow } from './automations-page-fixtures' +import type { AutomationListRow } from './automation-list-row-identity' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +const HOST_CATALOG = { + catalog: { entries: [], byStableKey: new Map(), hydration: {} }, + entries: [], + resolution: { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false + }, + rows: { rows: [], automations: [], capturedOwners: new Map(), groups: [], answered: true }, + loadCounts: { failedHostCount: 0, totalHostCount: 1 }, + selectHost: () => undefined, + recover: () => undefined, + refreshHosts: () => undefined, + notifyAuthorityChange: () => undefined +} as unknown as AutomationHostCatalogView + +function renderPanel( + rows: readonly AutomationListRow[], + query: string, + onQueryChange: (next: string) => void = () => undefined, + uncheckedNotice: string | null = null +): void { + act(() => { + root.render( + + 0} + hasFilteredListItems={rows.length > 0} + listSearchQuery={query} + isListSearchQueryTooLarge={false} + onListSearchQueryChange={onQueryChange} + searchCounts={{ + hostRowCount: rows.length, + visibleRowCount: rows.length, + searchActive: query !== '' + }} + hostCatalog={HOST_CATALOG} + externalManagersListed + externalScopeNotice={null} + externalManagersUncheckedNotice={uncheckedNotice} + onSelectHost={() => undefined} + onRecoverHost={() => undefined} + filteredRows={rows} + filteredExternalAutomationEntries={[]} + selectedRowKey={null} + selectedExternalKey={null} + relativeNow={0} + repoMap={new Map()} + worktreeMap={new Map()} + projectHostSetups={[]} + sshConnectionStates={new Map()} + runtimeStatusByEnvironmentId={new Map()} + hostTargetFor={() => null} + automationSourceHostAvailabilityByRowKey={new Map()} + isActionEnabled={() => true} + externalActionKey={null} + selectAutomationRow={() => undefined} + selectExternalKey={() => undefined} + setActivePaneTab={() => undefined} + runNow={() => undefined} + openEditDialog={() => undefined} + toggleAutomation={() => undefined} + requestDeleteAutomation={() => undefined} + requestExternalAction={() => undefined} + openEditExternalDialog={() => undefined} + openCreateDialog={() => undefined} + onOpenDetail={() => undefined} + onRefresh={() => undefined} + isRefreshing={false} + /> + + ) + }) +} + +function searchField(): HTMLInputElement | null { + return container.querySelector('input[aria-label="Search automations"]') +} + +describe('AutomationsListPanel search field persistence', () => { + it('keeps the search field on screen when the host has no rows', () => { + renderPanel([], '') + + expect(searchField()).not.toBeNull() + }) + + it('survives a refresh tick that momentarily empties the list', () => { + const row = makeAutomationListRow() + renderPanel([row], 'night') + const before = searchField() + before?.focus() + + renderPanel([], 'night') + + // Identity, not presence: a remount is what loses the caret and the query. + expect(searchField()).toBe(before) + expect(searchField()?.value).toBe('night') + expect(document.activeElement).toBe(before) + }) +}) + +describe('AutomationsListPanel unchecked hosts', () => { + it('shows a host it could not check instead of an unqualified empty list', () => { + renderPanel( + [], + '', + () => undefined, + 'External automation managers on web-01 could not be checked.' + ) + + expect(container.textContent).toContain( + 'External automation managers on web-01 could not be checked.' + ) + }) + + it('says nothing about unchecked hosts when there are none', () => { + renderPanel([], '') + + expect(container.textContent).not.toContain('could not be checked') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 2bfc63edac0..0a1c0efd0e7 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -1,59 +1,75 @@ -import React from 'react' -import { Plus } from 'lucide-react' +import React, { useRef } from 'react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { - Automation, AutomationRun, ExternalAutomationAction, ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' -import type { AutomationPaneTab } from './automation-page-state' -import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' -import { AutomationListLocalRows } from './AutomationListLocalRows' -import { AutomationListExternalRows } from './AutomationListExternalRows' -import { LIST_TABLE_CONTAINER_CLASS } from '@/lib/list-table-layout' -import { translate } from '@/i18n/i18n' -import type { - AutomationListFilter, - AutomationListSort, - AutomationListSortField, - AutomationListViewItem -} from './automation-list-view' -import { AutomationListTableHeader } from './AutomationListTableHeader' -import { AutomationListToolbar } from './AutomationListToolbar' -import { indexLatestAutomationRuns } from './automation-list-last-run' +import { clampAutomationListSearchQueryInput } from './automation-list-search' import { getAutomationListArrowNavigationTarget, type AutomationListArrowKey } from './automation-list-keyboard-navigation' +import type { AutomationListRow } from './automation-list-row-identity' +import type { AutomationPaneTab } from './automation-page-state' +import { AutomationListSearchField } from './AutomationListSearchField' +import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { ExternalAutomationScope } from './external-automation-scope-client' +import { AutomationListLocalRows } from './AutomationListLocalRows' +import { AutomationListExternalRows } from './AutomationListExternalRows' +import { AutomationListHostGroups } from './AutomationListHostGroups' +import { filterAutomationHostGroups } from './automation-host-list-rows' +import { AutomationHostPicker } from './AutomationHostPicker' +import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice' +import { AutomationListEmptyView } from './AutomationListEmptyView' +import { resolveAutomationListEmptyState } from './automation-list-empty-state' +import type { AutomationListSearchCounts } from './use-automation-list-search' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery' +import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout' +import { LIST_TABLE_CONTAINER_CLASS, LIST_TABLE_HEADER_CLASS } from '@/lib/list-table-layout' +import { translate } from '@/i18n/i18n' + +const TEMPLATE_EMPTY_STATES: ReadonlySet = new Set(['host-empty', 'all-hosts-empty']) +const EMPTY_AUTOMATION_RUNS: ReadonlyMap = new Map() type AutomationsListPanelProps = { hasListItems: boolean hasFilteredListItems: boolean - isListSearchActive: boolean - isListFilterActive: boolean listSearchQuery: string isListSearchQueryTooLarge: boolean onListSearchQueryChange: (query: string) => void - visibleItems: readonly AutomationListViewItem[] - listFilter: AutomationListFilter - onListFilterChange: (filter: AutomationListFilter) => void - listSort: AutomationListSort | null - onListSort: (field: AutomationListSortField) => void - // Why: use explicit ids, not resolved detail selection (which falls back to the - // first row) — list highlight should only mark a user-chosen row. - selectedId: string | null + searchCounts: AutomationListSearchCounts + hostCatalog: AutomationHostCatalogView + externalManagersListed: boolean + externalScopeNotice: string | null + externalManagersUncheckedNotice: string | null + onSelectHost: (filter: AutomationHostFilter) => void + onRecoverHost: ( + action: AutomationHostRecoveryAction, + entry?: AutomationHostCatalogEntry | null + ) => void + filteredRows: readonly AutomationListRow[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + selectedRowKey: string | null selectedExternalKey: string | null - runs: readonly AutomationRun[] + selectedExternal?: ExternalAutomationListEntry | null relativeNow: number repoMap: ReadonlyMap worktreeMap: ReadonlyMap @@ -63,80 +79,96 @@ type AutomationsListPanelProps = { string, { status: RuntimeStatus | null; checkedAt: number } > - automationHostTarget: AutomationHostTarget | null - automationSourceHostAvailabilityById: ReadonlyMap - hostLabelById: ReadonlyMap + hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null + automationSourceHostAvailabilityByRowKey: ReadonlyMap + hostLabelById?: ReadonlyMap + isActionEnabled: (row: AutomationListRow, action: AutomationRowAction) => boolean externalActionKey: string | null - selectAutomationId: (automationId: string | null) => void + selectAutomationRow: (rowKey: string | null) => void selectExternalKey: (externalKey: string | null) => void setActivePaneTab: (tab: AutomationPaneTab) => void - runNow: (automation: Automation) => void - openEditDialog: (automation: Automation) => void - toggleAutomation: (automation: Automation) => void - requestDeleteAutomation: (automation: Automation) => void + runNow: (row: AutomationListRow) => void + openEditDialog: (row: AutomationListRow) => void + toggleAutomation: (row: AutomationListRow) => void + requestDeleteAutomation: (row: AutomationListRow) => void requestExternalAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope + ) => void + openEditExternalDialog: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope ) => void - openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void openCreateDialog: (template?: AutomationTemplate) => void onOpenDetail: () => void onRefresh: () => void isRefreshing: boolean } -export function AutomationsListPanel({ - hasListItems, - hasFilteredListItems, - isListSearchActive, - isListFilterActive, - listSearchQuery, - isListSearchQueryTooLarge, - onListSearchQueryChange, - visibleItems, - listFilter, - onListFilterChange, - listSort, - onListSort, - selectedId, - selectedExternalKey, - runs, - relativeNow, - repoMap, - worktreeMap, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - automationSourceHostAvailabilityById, - hostLabelById, - externalActionKey, - selectAutomationId, - selectExternalKey, - setActivePaneTab, - runNow, - openEditDialog, - toggleAutomation, - requestDeleteAutomation, - requestExternalAction, - openEditExternalDialog, - openCreateDialog, - onOpenDetail, - onRefresh, - isRefreshing -}: AutomationsListPanelProps): React.JSX.Element { - // Why: one pass over runs for the whole list — each row renders its own - // component, so indexing per row would be O(rows × runs) on every re-render. - const lastRunByAutomationId = React.useMemo(() => indexLatestAutomationRuns(runs), [runs]) - const listScrollRef = React.useRef(null) - const pendingKeyboardScrollRef = React.useRef(false) - +export function AutomationsListPanel(props: AutomationsListPanelProps): React.JSX.Element { + const { + hasListItems, + hasFilteredListItems, + listSearchQuery, + isListSearchQueryTooLarge, + onListSearchQueryChange, + searchCounts, + hostCatalog, + externalManagersListed, + externalScopeNotice, + externalManagersUncheckedNotice, + onSelectHost, + onRecoverHost, + filteredRows, + filteredExternalAutomationEntries, + selectedRowKey, + selectedExternalKey, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById, + isActionEnabled, + externalActionKey, + selectAutomationRow, + selectExternalKey, + setActivePaneTab, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + requestExternalAction, + openEditExternalDialog, + openCreateDialog, + onOpenDetail, + onRefresh, + isRefreshing + } = props + const listRef = useRef(null) + const pickerRef = useRef(null) + const pendingKeyboardScrollRef = useRef(false) + const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows]) + const visibleRowKeys = React.useMemo(() => new Set(rowKeys), [rowKeys]) + const visibleItems = React.useMemo( + () => [ + ...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })), + ...filteredExternalAutomationEntries.map((entry) => ({ kind: 'external' as const, id: entry.key })) + ], + [filteredExternalAutomationEntries, filteredRows] + ) + useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: pickerRef }) const handleSearchArrowNavigate = React.useCallback( (key: AutomationListArrowKey) => { const next = getAutomationListArrowNavigationTarget({ items: visibleItems, - selectedId, + selectedId: selectedRowKey, selectedExternalKey, key }) @@ -145,55 +177,219 @@ export function AutomationsListPanel({ } const alreadySelected = next.kind === 'local' - ? selectedExternalKey === null && selectedId === next.id + ? selectedExternalKey === null && selectedRowKey === next.id : selectedExternalKey === next.id if (alreadySelected) { - listScrollRef.current - ?.querySelector('[data-current="true"]') - ?.scrollIntoView({ block: 'nearest' }) + listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }) return } pendingKeyboardScrollRef.current = true - // Why: arrows only move the highlight — detail is a full-page drill-in that - // unmounts this list, so opening it here would end navigation on first press. if (next.kind === 'local') { selectExternalKey(null) - selectAutomationId(next.id) + selectAutomationRow(next.id) } else { - selectAutomationId(null) + selectAutomationRow(null) selectExternalKey(next.id) - // External items have no runs tab; keep the pane tab valid for the next open. setActivePaneTab('overview') } }, [ - selectAutomationId, + selectAutomationRow, selectExternalKey, selectedExternalKey, - selectedId, + selectedRowKey, setActivePaneTab, visibleItems ] ) - React.useEffect(() => { if (!pendingKeyboardScrollRef.current) { return } pendingKeyboardScrollRef.current = false - listScrollRef.current - ?.querySelector('[data-current="true"]') - ?.scrollIntoView({ block: 'nearest' }) - }, [selectedExternalKey, selectedId]) + listRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [selectedExternalKey, selectedRowKey]) + const emptyStateInput = { + resolution: hostCatalog.resolution, + ...searchCounts, + externalManagersListed + } + const emptyState = resolveAutomationListEmptyState(emptyStateInput) + const rowProps = { + selectedRowKey, + isSelectedLocal: selectedExternalKey === null, + lastRunByAutomationId: EMPTY_AUTOMATION_RUNS, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + hostTargetFor, + automationSourceHostAvailabilityByRowKey, + hostLabelById, + isActionEnabled, + onSelect: (rowKey: string) => { + selectExternalKey(null) + selectAutomationRow(rowKey) + onOpenDetail() + }, + onRunNow: runNow, + onEdit: openEditDialog, + onToggle: toggleAutomation, + onDelete: requestDeleteAutomation + } return (
- {!hasListItems ? ( -
-
+
+
+
+ +
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + onArrowNavigate={handleSearchArrowNavigate} + /> + + + + + + {translate( + 'auto.components.automations.AutomationsPage.19a6e30eae', + 'Refresh automations' + )} + + +
+ +
+ + onRecoverHost(action)} + className="mb-2" + /> + + {externalManagersUncheckedNotice ? ( +

+ {externalManagersUncheckedNotice} +

+ ) : null} + {externalScopeNotice && hasFilteredListItems ? ( +

+ {externalScopeNotice} +

+ ) : null} + +
+ {hasFilteredListItems ? ( + <> +
+ + {translate('auto.components.automations.AutomationsPage.tableName', 'Name')} + + + {translate('auto.components.automations.AutomationDetail.18763ded26', 'Schedule')} + + + {translate('auto.components.automations.AutomationsPage.tableProject', 'Project')} + + + {translate('auto.components.automations.AutomationDetail.578ff46987', 'Next run')} + + + {translate('auto.components.automations.AutomationsPage.tableLastRun', 'Last run')} + + + {translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')} + + + {translate('auto.components.automations.AutomationDetail.2df8970cd5', 'Agent')} + + + {translate('auto.components.automations.AutomationsPage.tableActions', 'Actions')} + +
+
+ {hostCatalog.rows.groups.length > 0 ? ( + + ) : ( + + )} + { + selectAutomationRow(null) + selectExternalKey(entryKey) + setActivePaneTab('overview') + onOpenDetail() + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> +
+ + ) : ( + onRecoverHost(action)} + /> + )} + + {!hasListItems && TEMPLATE_EMPTY_STATES.has(emptyState.kind) ? ( +
{translate( 'auto.components.automations.AutomationsPage.d207ab4c25', @@ -226,91 +422,8 @@ export function AutomationsListPanel({ {translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
-
- ) : ( -
- - -
- {hasFilteredListItems ? ( - <> - -
- {visibleItems.map((item) => - item.kind === 'local' ? ( - { - selectExternalKey(null) - selectAutomationId(automationId) - onOpenDetail() - }} - onRunNow={runNow} - onEdit={openEditDialog} - onToggle={toggleAutomation} - onDelete={requestDeleteAutomation} - /> - ) : ( - { - selectAutomationId(null) - selectExternalKey(entryKey) - setActivePaneTab('overview') - onOpenDetail() - }} - onRequestAction={requestExternalAction} - onEdit={openEditExternalDialog} - /> - ) - )} -
- - ) : isListSearchActive || isListFilterActive ? ( -
- {translate( - 'auto.components.automations.AutomationsPage.noListMatches', - 'No automations match.' - )} -
- ) : null} -
-
- )} + ) : null} +
) } diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx new file mode 100644 index 00000000000..ea82fce1f9a --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -0,0 +1,297 @@ +// @vitest-environment happy-dom + +/** + * Where a new automation lands. + * + * Every case here is one the page previously answered by inferring a host from + * the draft's run context: the storage authority came from whichever machine the + * workspace happened to execute on, which is exactly what required invariant 1 + * forbids. These pin the stated-destination contract instead — the destination + * is chosen, shown, re-checked at submit, and refused when it no longer holds. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity' +import { + api, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + runtimeHost, + RUNTIME_ID, + RUNTIME_SELF_FILTER, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' + +installAutomationsPageHarness() + +const RUNTIME_REPO_ID = 'repo-2' +const RUNTIME_WORKSPACE_ID = 'workspace-2' + +const DESKTOP_SELF_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +}) +const RUNTIME_SELF_KEY = hostStableKey({ + authority: { kind: 'runtime', environmentId: RUNTIME_ID }, + selector: { kind: 'self' } +}) + +/** + * A project the runtime owns. It carries no `connectionId`, so a flat repo + * lookup reads it as local — the ambiguity the authority-scoped tables exist to + * remove. + */ +function addRuntimeProject(): void { + const repo = { + id: RUNTIME_REPO_ID, + displayName: 'gpu-orca', + path: '/repos/gpu-orca', + badgeColor: '#111111', + addedAt: 1, + worktreeBaseRef: 'main', + executionHostId: `runtime:${RUNTIME_ID}` + } as Repo + const worktree = { + id: RUNTIME_WORKSPACE_ID, + repoId: RUNTIME_REPO_ID, + displayName: 'main', + path: '/repos/gpu-orca', + branch: 'main' + } as Worktree + const setup: ProjectHostSetup = { + id: 'setup-2', + projectId: 'project-2', + hostId: `runtime:${RUNTIME_ID}`, + repoId: RUNTIME_REPO_ID, + path: '/repos/gpu-orca', + displayName: 'gpu-orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + const repos = mocks.state.repos as Repo[] + const setups = mocks.state.projectHostSetups as ProjectHostSetup[] + const worktreesByRepo = mocks.state.worktreesByRepo as Record + mocks.state.repos = [...repos, repo] + mocks.state.projectHostSetups = [...setups, setup] + mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] } + mocks.repoMap.set(RUNTIME_REPO_ID, repo) + mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree) +} + +/** The runtime answers a create; without this the RPC double returns an empty result. */ +function runtimeCreateReturns(automation: Automation): void { + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, method: string, params: unknown, options: unknown) => { + if (method === 'automation.create') { + return { automation } + } + return await previous?.(target, method, params, options) + } + ) +} + +async function openCreateDialogFor(projectId: string, workspaceId: string): Promise { + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId, + workspaceMode: 'existing', + workspaceId + })) + }) +} + +async function save(): Promise { + await act(async () => { + mocks.editorDialog?.onSave() + }) +} + +function runtimeCreateCalls(): unknown[][] { + return mocks.callRuntimeRpc.mock.calls.filter((call) => call[1] === 'automation.create') +} + +describe('AutomationsPage create destination', () => { + it('creates on the selected runtime rather than under the desktop authority', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await save() + + // The desktop is the client's own authority, never the selected host. + expect(api.automations.create).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(1) + expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ + destination: { selector: { kind: 'self' } } + }) + }) + + it('refuses a runtime-owned project under the desktop destination', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + addRuntimeProject() + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + await save() + + // A repo with no connection ID is not evidence of local: this one is the + // runtime's, and the desktop's Self host cannot hold an automation for it. + expect(api.automations.create).not.toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.open).toBe(true) + }) + + it('requires an explicit host choice when All hosts spans more than one host', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await save() + + expect(api.automations.create).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(0) + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.createDestination?.entries.map((entry) => entry.stableKey)).toEqual( + expect.arrayContaining([DESKTOP_SELF_KEY, RUNTIME_SELF_KEY]) + ) + + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) + }) + expect(mocks.editorDialog?.createDestination?.resolution.status).toBe('ready') + await save() + + expect(runtimeCreateCalls()).toHaveLength(1) + expect(api.automations.create).not.toHaveBeenCalled() + }) + + it('states the chosen host on the form before submit', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + + await renderPage() + await settleHostQueries() + await openCreateDialogFor(REPO_ID, WORKSPACE_ID) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) + }) + + const resolution = mocks.editorDialog?.createDestination?.resolution + // The owner shown is the storage authority, not the workspace's run host. + expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(RUNTIME_SELF_KEY) + expect(resolution?.status === 'ready' && resolution.authority).toEqual({ + kind: 'runtime', + environmentId: RUNTIME_ID, + pairingRevision: 4 + }) + }) + + it('fails closed when the destination host changes incarnation while the form is open', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + runtimeCreateReturns(makeAutomation({ id: 'a-new', name: 'Sweep' })) + addRuntimeProject() + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + + const { rerender } = await renderPage() + await settleHostQueries() + await openCreateDialogFor(RUNTIME_REPO_ID, RUNTIME_WORKSPACE_ID) + + // The host re-paired while the form was open: same stable key, new owner. + mocks.state.runtimeEnvironments = [ + { id: RUNTIME_ID, name: 'GPU box', createdAt: 1, pairingRevision: 9 } + ] + await rerender() + await save() + + expect(runtimeCreateCalls()).toHaveLength(0) + expect(api.automations.create).not.toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + expect(mocks.editorDialog?.open).toBe(true) + }) +}) + +describe('AutomationsPage edit fencing', () => { + async function editAndSave(row: AutomationListRow): Promise { + await act(async () => { + void mocks.listPanel?.openEditDialog(row) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Renamed' + })) + }) + await save() + } + + function updatePreconditions(): unknown[] { + return api.automations.update.mock.calls.map( + ([payload]) => (payload as { expectedOwner?: unknown }).expectedOwner + ) + } + + it('fences the save even when the record cannot be re-read', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + + await renderPage() + await settleHostQueries() + // The row is gone from its host by the time the form is saved. + scopedList([]) + await editAndSave(listedRow(automation.id)) + + expect(api.automations.update).toHaveBeenCalled() + expect(updatePreconditions()).not.toContain(undefined) + }) + + it('refuses the save when nothing names the record’s host', async () => { + const automation = makeAutomation({ id: 'a-1' }) + // No host answered, so no owner was ever captured, and the record is gone + // from the one list the client can still address. + api.automations.listScoped.mockRejectedValue(new Error('offline')) + api.automations.list.mockResolvedValue([]) + + await renderPage() + await settleHostQueries() + // No host answered, so the page never listed a row for it; the pre-catalog + // projection is the only key the user could have clicked. + await editAndSave(unscopedAutomationListRows([automation])[0]!) + + expect(api.automations.update).not.toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx new file mode 100644 index 00000000000..17b783b49d3 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +/** + * Row actions under a cross-authority ID collision. + * + * Two authorities may legitimately hold the same automation ID, so the copy the + * user acted on is named by its row, never by that ID. These pin the case a + * bare-ID owner map cannot express: the desktop's copy is listed first and the + * runtime's copy is listed second, so anything keyed by the ID alone answers + * with the runtime's owner and sends the desktop's row to the wrong machine. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import { + api, + installAutomationsPageHarness, + mocks, + renderPage, + runtimeHost, + scopedList, + SELF_PRECONDITION, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation } from './automations-page-fixtures' + +installAutomationsPageHarness() + +/** Desktop and runtime both hold `a-1`; the runtime's row is committed last. */ +async function collidingHosts(): Promise { + runtimeHost([makeAutomation({ id: 'a-1', name: 'Remote nightly' })], []) + const desktop = makeAutomation({ id: 'a-1', name: 'Desktop nightly' }) + scopedList([desktop]) + api.automations.list.mockResolvedValue([desktop]) + mocks.state.selectedAutomationId = 'a-1' +} + +function selectDesktopRow(): string { + const row = mocks.listPanel?.filteredRows.find( + (candidate) => candidate.automation.name === 'Desktop nightly' + ) + expect(row).toBeDefined() + return row?.key ?? '' +} + +/** Every runtime-bound automation call, so a misrouted action cannot hide in the noise. */ +function runtimeAutomationCalls(): string[] { + return mocks.callRuntimeRpc.mock.calls + .map((call) => String(call[1])) + .filter((method) => method.startsWith('automation.')) +} + +describe('AutomationsPage row actions under a colliding automation id', () => { + it('runs the selected desktop row on the desktop, not on the runtime holding the same id', async () => { + await collidingHosts() + const { container } = await renderPage() + await settleHostQueries() + expect( + [...container.querySelectorAll('[data-testid="automation-row"]')].map( + (row) => row.textContent + ) + ).toEqual(['Desktop nightly', 'Remote nightly']) + + await act(async () => { + mocks.listPanel?.selectAutomationRow(selectDesktopRow()) + }) + expect(mocks.detailPane?.selected?.name).toBe('Desktop nightly') + mocks.callRuntimeRpc.mockClear() + await act(async () => { + mocks.detailPane?.runNow(mocks.detailPane.selected!) + }) + + expect(runtimeAutomationCalls()).not.toContain('automation.runNow') + expect(api.automations.runNow).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + }) + + it('pauses the selected desktop row on the desktop', async () => { + await collidingHosts() + await renderPage() + await settleHostQueries() + + await act(async () => { + mocks.listPanel?.selectAutomationRow(selectDesktopRow()) + }) + mocks.callRuntimeRpc.mockClear() + await act(async () => { + mocks.detailPane?.toggleAutomation(mocks.detailPane.selected!) + }) + + expect(api.automations.update).toHaveBeenCalledWith({ + id: 'a-1', + updates: { enabled: false }, + expectedOwner: SELF_PRECONDITION, + destination: undefined + }) + expect(runtimeAutomationCalls()).not.toContain('automation.update') + }) + + it('deletes the selected desktop row on the desktop', async () => { + await collidingHosts() + await renderPage() + await settleHostQueries() + + await act(async () => { + mocks.listPanel?.selectAutomationRow(selectDesktopRow()) + }) + await act(async () => { + mocks.detailPane?.requestDeleteAutomation(mocks.detailPane.selected!) + }) + mocks.callRuntimeRpc.mockClear() + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + expect(api.automations.delete).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + expect(runtimeAutomationCalls()).not.toContain('automation.delete') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx new file mode 100644 index 00000000000..67f74cc3704 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom + +/** + * What the page actually asks the main process about external managers. + * + * The engine-level tests hand a pre-narrowed scope list to the client, so they + * prove the plumbing and nothing about the producer. These drive the page: the + * selection is the input and the IPC calls it issues are the observation. + */ + +import { describe, expect, it } from 'vitest' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { + api, + DESKTOP_SELF_OWNER, + installAutomationsPageHarness, + mocks, + renderPage, + runtimeHost, + RUNTIME_SELF_FILTER, + settleHostQueries +} from './automations-page-test-harness' +import { makeExternalManager } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const DESKTOP_SELF_FILTER = { + kind: 'host' as const, + host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } +} + +function sshFilter(targetId: string): unknown { + return { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } } + } +} + +/** Registered SSH targets, each with the generation that makes it fenceable. */ +function withSshHosts(...targetIds: string[]): void { + mocks.state.sshTargetLabels = new Map(targetIds.map((targetId) => [targetId, targetId])) + mocks.state.sshTargetGenerations = new Map(targetIds.map((targetId) => [targetId, 1])) + mocks.state.sshConnectionStates = new Map( + targetIds.map((targetId) => [targetId, { status: 'connected' }]) + ) +} + +function probedOwners(): AutomationOwnerRef[] { + return api.automations.listExternalManagerForOwner.mock.calls.map( + (call) => (call[0] as { owner: AutomationOwnerRef }).owner + ) +} + +function lastRetainedOwners(): readonly AutomationOwnerRef[] { + const calls = api.automations.retainExternalScopes.mock.calls + return (calls.at(-1)?.[0] as { owners: readonly AutomationOwnerRef[] } | undefined)?.owners ?? [] +} + +function hermesOnly(): void { + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) +} + +describe('AutomationsPage external manager probes', () => { + it('probes no SSH host when the selection is the local desktop', async () => { + withSshHosts('staging', 'web-01', 'web-02') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + const sshProbes = probedOwners().filter((owner) => owner.selector.kind === 'ssh') + expect(sshProbes).toEqual([]) + expect(probedOwners()).toEqual([DESKTOP_SELF_OWNER, DESKTOP_SELF_OWNER]) + }) + + it('retains only the selected host, so probes elsewhere are cancelled', async () => { + withSshHosts('staging', 'web-01', 'web-02') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + expect(lastRetainedOwners()).toEqual([DESKTOP_SELF_OWNER]) + }) + + it('cancels the previous host as soon as the filter moves off it', async () => { + withSshHosts('staging') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + const { rerender } = await renderPage() + await settleHostQueries() + mocks.state.automationHostFilter = sshFilter('staging') + await rerender() + await settleHostQueries() + + expect(lastRetainedOwners()).toEqual([ + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 'staging', targetGeneration: 1 } + } + ]) + }) + + it('lists no external rows for a host whose managers it says are not listed', async () => { + runtimeHost([], []) + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + expect(mocks.listPanel?.externalManagersListed).toBe(false) + expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([]) + }) + + it('drops the previous host rows when the selection moves, not when the new probe lands', async () => { + withSshHosts('staging') + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + const { rerender } = await renderPage() + await settleHostQueries() + expect(mocks.listPanel?.filteredExternalAutomationEntries.length).toBeGreaterThan(0) + + // The new host never answers, so anything still listed belongs to the old one. + api.automations.listExternalManagerForOwner.mockImplementation( + async () => await new Promise(() => undefined) + ) + mocks.state.automationHostFilter = sshFilter('staging') + await rerender() + await settleHostQueries() + + expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([]) + }) + + it('reports a host it could not check rather than showing it as clean', async () => { + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: null, error: 'hermes probe failed: channel closed', updatedAt: 0 } + : { manager: null, error: null, updatedAt: 0 } + ) + + await renderPage() + await settleHostQueries() + + const desktopLabel = mocks.listPanel?.hostCatalog.entries[0]?.label + expect(mocks.listPanel?.externalManagersUncheckedNotice).toBe( + `External automation managers on ${desktopLabel} could not be checked.` + ) + }) + + it('says nothing about unchecked hosts when every probe answered', async () => { + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + hermesOnly() + + await renderPage() + await settleHostQueries() + + expect(mocks.listPanel?.externalManagersUncheckedNotice).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx new file mode 100644 index 00000000000..6a9811247d5 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment happy-dom + +/** + * What the page refetches, and whose row survives it. + * + * A refresh reaches one authority at a time, so re-deriving the selection or the + * run history from its answer drops — or worse, mislabels — every row another + * host holds. Under All hosts that is the normal case, not the edge case. + */ + +import { act } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { + api, + installAutomationsPageHarness, + mocks, + refreshOnFocus, + renderPage, + RUNTIME_SELF_FILTER, + runtimeHost, + scopedList, + SELF_PRECONDITION, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, makeRun } from './automations-page-fixtures' + +installAutomationsPageHarness() + +async function openAutomationRow(container: HTMLElement, name: string): Promise { + const row = [ + ...container.querySelectorAll('[data-testid="automation-row"]') + ].find((candidate) => candidate.textContent === name) + expect(row).toBeTruthy() + await act(async () => row?.click()) + await settleHostQueries() +} + +describe('AutomationsPage refresh', () => { + it('never asks a host for every run it has retained', async () => { + const run = makeRun({ id: 'run-1', automationId: 'a-1' }) + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1' })]) + api.automations.listRuns.mockResolvedValue([run]) + + await renderPage() + + // Every call names one automation: a host's whole history is never the + // page's to hold, and asking for it made the cost scale with retention. + for (const [args] of api.automations.listRuns.mock.calls) { + expect(args?.automationId).toBeTruthy() + } + }) + + it('reads row usage from the authority projection rather than from runs', async () => { + const usageSummary = { + knownRuns: 2, + unavailableRuns: 0, + inputTokens: 900, + outputTokens: 300, + cacheTokens: 0, + reasoningOutputTokens: 34, + totalTokens: 1234, + estimatedCostUsd: 0.5 + } + api.automations.listScoped.mockResolvedValue({ + automations: [makeAutomation({ id: 'a-1' })], + items: [{ automationId: 'a-1', selector: { kind: 'self' }, usageSummary }], + orphanCount: 0 + }) + + await renderPage() + + expect(mocks.listPanel?.filteredRows[0]?.usageSummary).toEqual(usageSummary) + }) + + it('does not re-list through the active runtime just because one is selected', async () => { + mocks.state.settings = { + ...(mocks.state.settings as Record), + activeRuntimeEnvironmentId: 'gpu' + } + mocks.callRuntimeRpc.mockResolvedValue({ automations: [], runs: [] }) + + await renderPage() + + // The ambient runtime is not evidence about any row. The desktop answers + // the unscoped arm; a runtime's rows arrive through its own catalog entry. + expect(api.automations.list).toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'gpu' }, + 'automation.list', + undefined, + { timeoutMs: 15_000 } + ) + }) + + // The manual All-hosts refresh: without a control wired to it, a host whose + // entry stopped short has nothing that re-queries it. + it('re-queries every host in view from the header refresh action', async () => { + const { container } = await renderPage() + api.automations.listScoped.mockClear() + + const refresh = container.querySelector('[aria-label="Refresh automations"]') + expect(refresh).not.toBeNull() + await act(async () => refresh?.click()) + + // Inside the 30s TTL, so only a manual refresh can produce this call. + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + }) + + it('does not re-run the whole refresh when the settings identity changes', async () => { + const { rerender } = await renderPage() + expect(api.automations.list).toHaveBeenCalledTimes(1) + + mocks.state.settings = { ...(mocks.state.settings as Record) } + await rerender() + + // Settings no longer aim the list, so a new settings object is not a reason + // to re-fetch every host. + expect(api.automations.list).toHaveBeenCalledTimes(1) + }) +}) + +describe('AutomationsPage selection', () => { + it('loads the first automation into the detail pane and fetches its runs', async () => { + const run = makeRun({ id: 'run-1', automationId: 'a-1' }) + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + api.automations.listRuns.mockImplementation(async (args?: { automationId?: string }) => + args?.automationId === 'a-1' ? [run] : [] + ) + + const { container } = await renderPage() + await openAutomationRow(container, 'Nightly') + + // One fenced read, owned by the row's own host: the page-level refresh used + // to fetch this history too, unfenced and always against the desktop. + expect(api.automations.listRuns).toHaveBeenCalledWith({ + automationId: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + expect(container.querySelector('[data-testid="detail-name"]')?.textContent).toBe('Nightly') + expect(mocks.detailPane?.selectedRuns).toEqual([run]) + }) + + it('fetches the newly selected automation runs when the selection changes', async () => { + scopedList([ + makeAutomation({ id: 'a-1', name: 'Nightly' }), + makeAutomation({ id: 'a-2', name: 'Weekly' }) + ]) + + const { container, rerender } = await renderPage() + api.automations.listRuns.mockClear() + + mocks.state.selectedAutomationId = 'a-2' + await rerender() + await openAutomationRow(container, 'Weekly') + + // The history read carries the row's fence too: a read against a host that + // no longer owns the record is refused rather than answered from the wrong one. + expect(api.automations.listRuns).toHaveBeenCalledWith({ + automationId: 'a-2', + expectedOwner: SELF_PRECONDITION + }) + expect(mocks.detailPane?.selected?.id).toBe('a-2') + }) +}) + +describe('AutomationsPage multi-host selection', () => { + it('keeps a selection that lives on another host when the window regains focus', async () => { + runtimeHost([makeAutomation({ id: 'a-remote', name: 'Remote nightly' })], []) + const desktop = makeAutomation({ id: 'a-desktop', name: 'Desktop nightly' }) + scopedList([desktop]) + api.automations.list.mockResolvedValue([desktop]) + const setSelectedAutomationId = vi.fn((id: string | null) => { + mocks.state.selectedAutomationId = id + }) + mocks.state.selectedAutomationId = 'a-remote' + mocks.state.setSelectedAutomationId = setSelectedAutomationId + + const { container, rerender } = await renderPage() + await settleHostQueries() + await refreshOnFocus() + await rerender() + + // The refresh reaches one authority, so re-deriving the selection from its + // list dropped every row that authority does not hold. + expect(setSelectedAutomationId).not.toHaveBeenCalled() + await openAutomationRow(container, 'Remote nightly') + expect(container.querySelector('[data-testid="detail-name"]')?.textContent).toBe( + 'Remote nightly' + ) + }) + + it('shows the selected host’s runs when another authority holds the same id', async () => { + runtimeHost( + [makeAutomation({ id: 'a-1', name: 'Remote nightly' })], + [makeRun({ id: 'run-remote', automationId: 'a-1' })] + ) + // Same ID, different machine: legal, because an ID is unique only inside + // one authority. The desktop is the host the page can always reach. + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-1', name: 'Desktop nightly' })]) + api.automations.listRuns.mockResolvedValue([ + makeRun({ id: 'run-desktop', automationId: 'a-1' }) + ]) + scopedList([]) + mocks.state.automationHostFilter = RUNTIME_SELF_FILTER + mocks.state.selectedAutomationId = 'a-1' + + const { container } = await renderPage() + await settleHostQueries() + await refreshOnFocus() + await openAutomationRow(container, 'Remote nightly') + + expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') + expect(mocks.detailPane?.selectedRuns.map((run) => run.id)).toEqual(['run-remote']) + }) + + it('keeps both authorities’ rows on screen and selectable under All hosts', async () => { + runtimeHost( + [makeAutomation({ id: 'a-1', name: 'Remote nightly' })], + [makeRun({ id: 'run-remote', automationId: 'a-1' })] + ) + scopedList([makeAutomation({ id: 'a-1', name: 'Desktop nightly' })]) + // The stored id names the record; only the row key can say whose copy. + mocks.state.selectedAutomationId = 'a-1' + + const { container } = await renderPage() + await settleHostQueries() + + // Two stored records, not one row and a silent drop into #44's empty state. + expect( + [...container.querySelectorAll('[data-testid="automation-row"]')].map( + (row) => row.textContent + ) + ).toEqual(['Desktop nightly', 'Remote nightly']) + + const remote = mocks.listPanel?.filteredRows.find( + (row) => row.automation.name === 'Remote nightly' + ) + await act(async () => { + mocks.listPanel?.selectAutomationRow(remote?.key ?? '') + }) + + expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx new file mode 100644 index 00000000000..f7ad0be65a7 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +/** + * A run must change the list the user is looking at. + * + * Same shape as the save path: once any host has answered, the page renders the + * per-host cache, so the legacy `automations` state that `refresh()` writes is + * off screen and the cached row keeps its pre-run schedule until the entry is + * invalidated. A run is not a definition write, but it does rewrite what this + * host reports for the row, and that is what the list shows. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { + api, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const DESKTOP_SELF_FILTER = { + kind: 'host' as const, + host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } +} + +const BEFORE_RUN = makeAutomation({ id: 'a-1', name: 'Nightly', nextRunAt: 1_000 }) +const AFTER_RUN: Automation = { ...BEFORE_RUN, nextRunAt: 2_000, lastRunAt: 1_500 } + +/** The desktop store now reports the post-run projection; every later read must say so. */ +function desktopStoreHolds(automations: Automation[]): void { + api.automations.list.mockResolvedValue(automations) + scopedList(automations) +} + +/** The next-run column reads this; the mocked list panel renders only names. */ +function listedNextRunAt(): number | null | undefined { + return mocks.listPanel?.filteredRows[0]?.automation.nextRunAt +} + +describe('AutomationsPage run visibility', () => { + it('shows the post-run schedule the host reports, not the row the run started from', async () => { + desktopStoreHolds([BEFORE_RUN]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + + await renderPage() + await settleHostQueries() + expect(listedNextRunAt()).toBe(1_000) + + desktopStoreHolds([AFTER_RUN]) + const row = listedRow('a-1') + await act(async () => { + mocks.listPanel?.runNow(row) + }) + await settleHostQueries() + + expect(api.automations.runNow).toHaveBeenCalledTimes(1) + expect(listedNextRunAt()).toBe(2_000) + }) + + it('re-asks the host the run was dispatched to', async () => { + desktopStoreHolds([BEFORE_RUN]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + + await renderPage() + await settleHostQueries() + const listedBefore = api.automations.listScoped.mock.calls.length + + await act(async () => { + mocks.listPanel?.runNow(listedRow('a-1')) + }) + await settleHostQueries() + + // The cache TTL has not expired, so a refetch here can only come from the + // invalidation the run itself fed in. + expect(api.automations.listScoped.mock.calls.length).toBeGreaterThan(listedBefore) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx new file mode 100644 index 00000000000..4581a5fee7e --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +/** + * A save must change the list the user is looking at. + * + * The page renders the per-host cache once any host has answered, so the legacy + * `automations` state a save writes is no longer on screen. These pin the only + * thing the user can observe: the row is there after the save returns, whether + * or not the authority's `automationsChanged` event ever arrives. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' +import { + api, + installAutomationsPageHarness, + mocks, + renderPage, + rows, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const DESKTOP_SELF_FILTER = { + kind: 'host' as const, + host: { authority: { kind: 'desktop' as const }, selector: { kind: 'self' as const } } +} + +const CREATED = makeAutomation({ id: 'a-new', name: 'Sweep' }) + +/** The desktop store now holds the created row; every later read must say so. */ +function desktopStoreHolds(automations: Automation[]): void { + api.automations.list.mockResolvedValue(automations) + scopedList(automations) +} + +async function createSweep(): Promise { + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: REPO_ID, + workspaceMode: 'existing', + workspaceId: WORKSPACE_ID + })) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + await settleHostQueries() +} + +describe('AutomationsPage save visibility', () => { + it('lists a newly created automation on the host it was saved to', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage() + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + desktopStoreHolds([CREATED]) + await createSweep() + + expect(api.automations.create).toHaveBeenCalledTimes(1) + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('lists it when the authority publishes its scoped change event', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage() + await settleHostQueries() + + desktopStoreHolds([CREATED]) + await createSweep() + // What `automations:create` publishes for a Self record, verbatim. + await act(async () => { + window.dispatchEvent( + new CustomEvent(AUTOMATIONS_CHANGED_EVENT, { + detail: { reason: 'definition', selector: { kind: 'self' } } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('lists it after the page is reopened', async () => { + desktopStoreHolds([]) + mocks.state.automationHostFilter = DESKTOP_SELF_FILTER + api.automations.create.mockResolvedValue(CREATED) + + await renderPage() + await settleHostQueries() + desktopStoreHolds([CREATED]) + await createSweep() + + const reopened = await renderPage() + await settleHostQueries() + + expect(rows(reopened.container, 'automation-row')).toEqual(['Sweep']) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx new file mode 100644 index 00000000000..80bd234cf6b --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -0,0 +1,636 @@ +// @vitest-environment happy-dom + +/** + * Characterization tests for the automations page. + * + * This file pins the page's *current* observable orchestration — which requests + * it makes, in what shape, and what it hands to its children — so the Step 6/7/9 + * rewrites have a safety net. Several assertions describe behavior those steps + * will deliberately replace; each is marked with the step that owns the change, + * so an edit there reads as intended rather than as a regression. + * + * The mount rig, child stand-ins, and preload API double live in + * `automations-page-test-harness`; the refresh triggers and which row's history + * they leave on screen live in `AutomationsPage.refresh-selection.test.tsx`. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import { + AUTOMATION_ORPHAN_ISSUES, + type AutomationListParams +} from '../../../../shared/automation-list-scope' +import { + api, + DESKTOP_SELF_OWNER, + installAutomationsPageHarness, + listedRow, + mocks, + renderPage, + rows, + scopedList, + SELF_PRECONDITION +} from './automations-page-test-harness' +import { + makeAutomation, + makeExternalManager, + REPO_ID, + WORKSPACE_ID +} from './automations-page-fixtures' + +installAutomationsPageHarness() + +describe('AutomationsPage list rendering', () => { + it('renders the rows the host-scoped read returned, not the unscoped list', async () => { + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })]) + scopedList([ + makeAutomation({ id: 'a-1', name: 'Nightly' }), + makeAutomation({ id: 'a-2', name: 'Weekly' }) + ]) + + const { container } = await renderPage() + + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + expect(rows(container, 'automation-row')).toEqual(['Nightly', 'Weekly']) + }) + + it('falls back to the unscoped list until a host has answered', async () => { + api.automations.list.mockResolvedValue([makeAutomation({ id: 'a-9', name: 'Unscoped' })]) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + const { container } = await renderPage() + + // Why: a host that never answered is not an empty host, and the rows the + // page already had are better than none. Step 9 removes this arm. + expect(rows(container, 'automation-row')).toEqual(['Unscoped']) + }) + + it('renders the empty state when the host has no automations', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + + const { container } = await renderPage() + + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull() + expect(rows(container, 'automation-row')).toEqual([]) + }) + + it('handles a failed list instead of leaking an unhandled rejection', async () => { + // Before Step 7b this was pinned as *unhandled*: refresh() was try/finally + // with no catch and was called as `void refresh()`. + const unhandled: unknown[] = [] + const capture = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', capture) + api.automations.list.mockRejectedValue(new Error('offline')) + scopedList([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + + const { container } = await renderPage() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + process.off('unhandledRejection', capture) + + expect(unhandled).toEqual([]) + expect(container.querySelector('[data-testid="list-panel"]')).not.toBeNull() + // The host answered for itself, so its rows survive the unscoped failure. + expect(rows(container, 'automation-row')).toEqual(['Nightly']) + }) + + it('reports a host that could not be loaded, with a way to retry it', async () => { + api.automations.list.mockRejectedValue(new Error('offline')) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + await renderPage() + + expect(mocks.listPanel?.hostCatalog.loadCounts).toEqual({ + failedHostCount: 1, + totalHostCount: 1 + }) + const entry = mocks.listPanel?.hostCatalog.entries[0] + api.automations.listScoped.mockClear() + await act(async () => { + mocks.listPanel?.hostCatalog.recover('retry', entry) + }) + + // Retry re-queries that host rather than reloading the page. + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + }) + + it('lists external managers per captured owner rather than probing the machine', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + + const { container } = await renderPage() + + expect(api.automations.listExternalManagerForOwner).toHaveBeenCalledWith({ + owner: DESKTOP_SELF_OWNER, + provider: 'hermes' + }) + expect(rows(container, 'external-row')).toEqual(['Hermes job']) + }) + + it('keeps the edited external automation selected after its save', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager(), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.updateExternalForOwner.mockResolvedValue(undefined) + + await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to edit') + } + + await act(async () => { + mocks.listPanel?.openEditExternalDialog(entry.manager, entry.job, entry.scope) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + // The row key and the post-save selection are built by two different call + // sites; if they ever stop agreeing, selection silently falls through to the + // first row and the user's edit appears to open someone else's automation. + expect(api.automations.updateExternalForOwner).toHaveBeenCalled() + expect(mocks.listPanel?.selectedExternal?.key).toBe(entry.key) + }) + + it('routes each authority’s action to its own host when both report hermes:local', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager({ id: 'hermes:local' }), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.runExternalActionForOwner.mockResolvedValue(undefined) + + await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to act on') + } + // The second authority a multi-host build will list: same provider, same + // `hermes:local` manager ID, different machine. The desktop fixture is the + // only host that actually answers, so a scope recovered from the manager ID + // would resolve BOTH rows to it and pause the wrong host's cron job. + const runtimeScope = { + ...entry.scope, + owner: { + authority: { kind: 'runtime' as const, environmentId: 'env-7', pairingRevision: 1 }, + selector: { kind: 'self' as const } + } + } + + await act(async () => { + mocks.listPanel?.requestExternalAction(entry.manager, entry.job, 'pause', entry.scope) + }) + await act(async () => { + mocks.listPanel?.requestExternalAction(entry.manager, entry.job, 'pause', runtimeScope) + }) + + const owners = api.automations.runExternalActionForOwner.mock.calls.map( + (call) => (call[0] as { owner: unknown }).owner + ) + expect(owners).toEqual([entry.scope.owner, runtimeScope.owner]) + }) + + it('reads runs from the host the table names, not the one holding the manager ID', async () => { + api.automations.list.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockImplementation( + async ({ provider }: { provider: string }) => + provider === 'hermes' + ? { manager: makeExternalManager({ id: 'hermes:local' }), error: null, updatedAt: 1 } + : { manager: null, error: null, updatedAt: 1 } + ) + api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 }) + + const { container } = await renderPage() + const entry = mocks.listPanel?.filteredExternalAutomationEntries[0] + if (!entry) { + throw new Error('no external entry to read runs for') + } + const runtimeScope = { + ...entry.scope, + owner: { + authority: { kind: 'runtime' as const, environmentId: 'env-7', pairingRevision: 1 }, + selector: { kind: 'self' as const } + } + } + + await act(async () => { + container.querySelector('[data-testid="external-row"]')?.click() + }) + + await act(async () => { + await mocks.detailPane?.fetchExternalAutomationRuns({ + scope: runtimeScope, + manager: entry.manager, + job: entry.job, + page: 0, + pageSize: 8 + }) + }) + + // Run output is read, not written, but a wrong host still shows one machine's + // logs under another's automation — and the desktop is the only host listed, + // so an ID-keyed lookup would answer with it every time. + expect(api.automations.listExternalRunsForOwner).toHaveBeenCalledWith( + expect.objectContaining({ owner: runtimeScope.owner, jobId: entry.job.id }) + ) + }) + + it('reports that a runtime host lists no external managers this release', async () => { + await renderPage() + + // Desktop + Self is the only host in the fixture, and it is in scope. + expect(mocks.listPanel?.externalManagersListed).toBe(true) + expect(mocks.listPanel?.externalScopeNotice).toBeNull() + }) +}) + +describe('AutomationsPage host filter', () => { + it('hands the catalog and the resolved filter to the picker', async () => { + await renderPage() + + expect(mocks.listPanel?.hostCatalog.resolution.effective).toEqual({ kind: 'all' }) + expect(mocks.listPanel?.hostCatalog.entries.map((entry) => entry.kind)).toEqual(['self']) + }) + + it('groups rows by authority under All hosts', async () => { + scopedList([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + + await renderPage() + + const groups = mocks.listPanel?.hostCatalog.rows.groups ?? [] + expect(groups.map((group) => group.authorityKey)).toEqual(['authority:desktop']) + expect(groups[0]?.hosts[0]?.rows.map((row) => row.automation.name)).toEqual(['Nightly']) + }) + + it('feeds the search layer the counts and host labels it already computed', async () => { + scopedList([makeAutomation({ id: 'a-1', name: 'Nightly' })]) + + await renderPage() + + expect(mocks.listPanel?.searchCounts).toEqual({ + hostRowCount: 1, + visibleRowCount: 1, + searchActive: false + }) + expect(mocks.listPanel?.hostCatalog.rows.rows[0]?.hostLabel).toBeTruthy() + }) + + it('persists a host selection through the store rather than page state', async () => { + await renderPage() + await act(async () => { + mocks.listPanel?.onSelectHost({ kind: 'all' }) + }) + + expect(mocks.setAutomationHostFilter).toHaveBeenCalledWith({ kind: 'all' }) + }) +}) + +describe('AutomationsPage run navigation', () => { + it('does not leave a run navigation pending when the history read fails', async () => { + api.automations.list.mockResolvedValue([ + makeAutomation({ id: 'a-1' }), + makeAutomation({ id: 'a-2' }) + ]) + scopedList([makeAutomation({ id: 'a-1' }), makeAutomation({ id: 'a-2' })]) + api.automations.listRuns.mockImplementation( + async (args: { automationId: string; expectedOwner?: unknown }) => { + if (args.automationId === 'a-2' && args.expectedOwner) { + throw new Error('web-01 is not connected') + } + return [] + } + ) + mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation + const { rerender } = await renderPage() + + mocks.state.selectedAutomationId = 'a-2' + mocks.state.pendingAutomationRunNavigation = { + automationId: 'a-2', + runId: 'run-9', + hostId: null + } + await rerender() + await rerender() + + // The navigation is persisted UI state, so stuck here means stuck across reopens. + expect(mocks.setPendingRunNavigation).toHaveBeenCalledWith(null) + expect(mocks.detailPane?.selectedRunsNotice?.message).toContain('web-01 is not connected') + }) +}) + +describe('AutomationsPage mutations', () => { + it('routes Run Now to the desktop authority', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + await act(async () => { + mocks.listPanel?.runNow(listedRow(automation.id)) + }) + + // Step 6: the request names the owner captured when the row was listed. + expect(api.automations.runNow).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + }) + + it('routes enable/disable to an update on the owning authority', async () => { + const automation = makeAutomation({ id: 'a-1', enabled: true }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + expect(api.automations.update).toHaveBeenCalledWith({ + id: 'a-1', + updates: { enabled: false }, + expectedOwner: SELF_PRECONDITION, + destination: undefined + }) + }) + + it('re-reads the record before opening the edit dialog', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + api.automations.list.mockClear() + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + // Step 6: hydration re-reads the row's own scope; the ambient list is untouched. + expect(api.automations.list).not.toHaveBeenCalled() + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'self' } }) + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.isEditing).toBe(true) + }) + + it('opens the create dialog without an editing target', async () => { + await renderPage() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.isEditing).toBe(false) + }) + + it('deletes through the owning authority once the dialog confirms', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + await act(async () => { + mocks.listPanel?.requestDeleteAutomation(listedRow(automation.id)) + }) + expect(mocks.deleteDialog?.deleteTarget?.id).toBe('a-1') + + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + expect(api.automations.delete).toHaveBeenCalledWith({ + id: 'a-1', + expectedOwner: SELF_PRECONDITION + }) + }) + + it('creates at an explicitly stated destination rather than an inferred host', async () => { + api.automations.list.mockResolvedValue([]) + + await renderPage() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: REPO_ID, + workspaceMode: 'existing', + workspaceId: WORKSPACE_ID + })) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + expect(api.automations.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sweep' }), + { + destination: { selector: { kind: 'self' } } + } + ) + }) + + it('refreshes after a mutation so the list reflects the write', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + + await renderPage() + api.automations.list.mockClear() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + expect(api.automations.list).toHaveBeenCalled() + }) +}) + +describe('AutomationsPage owner conflicts', () => { + function conflict(code: string): Error { + return new Error(`The host changed: ${code}`) + } + + function notice(container: HTMLElement): HTMLElement | null { + return container.querySelector('[data-testid="automation-owner-conflict"]') + } + + it('surfaces a changed owner as a recoverable notice instead of a thrown error', async () => { + const automation = makeAutomation({ id: 'a-1', enabled: true }) + api.automations.list.mockResolvedValue([automation]) + api.automations.update.mockRejectedValue(conflict('automation_owner_changed')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + expect(notice(container)?.textContent).toContain("This automation's host changed") + expect(notice(container)?.textContent).toContain('Retry') + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("offers no recovery action when the automation's SSH host was deregistered", async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.delete.mockRejectedValue(conflict('automation_target_removed')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.requestDeleteAutomation(listedRow(automation.id)) + }) + await act(async () => { + mocks.deleteDialog?.onConfirm() + }) + + // Nothing the page can retry or reconnect re-registers a removed host. + expect(notice(container)?.textContent).toContain('SSH host was removed') + expect(notice(container)?.textContent).not.toContain('Retry') + }) + + it('refuses a save inside the dialog that hides the page, not behind it', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.update.mockRejectedValue(conflict('automation_owner_changed')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + prompt: 'Edited' + })) + }) + await act(async () => { + mocks.editorDialog?.onSave() + }) + + // The dialog stays open over the page, so a notice posted to the page is a + // save that did nothing at all as far as the user can tell. + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.notice?.message).toContain("This automation's host changed") + expect(notice(container)).toBeNull() + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('tells the user to update a server that cannot fence the request', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.runNow.mockRejectedValue(conflict('automation_owner_fencing_required')) + + const { container } = await renderPage() + await act(async () => { + mocks.listPanel?.runNow(listedRow(automation.id)) + }) + + expect(notice(container)?.textContent).toContain('Update server') + expect(mocks.toastMessage).not.toHaveBeenCalled() + }) + + it('refuses to open the editor on a row whose owner moved', async () => { + const automation = makeAutomation({ id: 'a-1' }) + + const { container } = await renderPage() + // The row was captured by the initial host read; the re-read is what fails. + api.automations.listScoped.mockRejectedValue(conflict('automation_owner_changed')) + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + expect(mocks.editorDialog?.open).toBe(false) + expect(notice(container)).not.toBeNull() + }) + + it('still opens the editor when the re-read merely failed', async () => { + const automation = makeAutomation({ id: 'a-1' }) + + await renderPage() + api.automations.listScoped.mockRejectedValue(new Error('offline')) + await act(async () => { + mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + // Why: a transport failure degrades to the copy on screen, as it did before Step 6. + expect(mocks.editorDialog?.open).toBe(true) + expect(mocks.editorDialog?.isEditing).toBe(true) + }) + + it('keeps the unfenced call for a row no host read has answered for', async () => { + // The host never answered, so no row carries an owner and none may be + // invented; the request goes out unfenced rather than against a guess. + const automation = makeAutomation({ id: 'a-1', enabled: true }) + api.automations.list.mockResolvedValue([automation]) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + await renderPage() + await act(async () => { + mocks.listPanel?.toggleAutomation(listedRow(automation.id)) + }) + + // Why: Step 9 removes this legacy arm once every row carries a captured owner. + expect(api.automations.update).toHaveBeenCalledWith({ + id: 'a-1', + updates: { enabled: false } + }) + }) + + // A mixed-host folder workspace is the path that reaches this without a host + // ever being removed, so the orphan bucket has to hold rows on a healthy machine. + it('lists an ambiguous-workspace row in the orphan bucket and keeps History on it', async () => { + const orphaned = makeAutomation({ id: 'a-2', name: 'Ambiguous' }) + api.automations.listScoped.mockImplementation(async (params: AutomationListParams) => + params.selector?.kind === 'orphan' + ? { + automations: [orphaned], + items: [ + { + automationId: 'a-2', + selector: { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous } + } + ], + orphanCount: 1 + } + : { + automations: [makeAutomation()], + items: [{ automationId: 'a-1', selector: { kind: 'self' } }], + orphanCount: 1 + } + ) + + const { container, rerender } = await renderPage() + // The orphan scope is only listed once an authority has reported a count, + // so the entry appears on the catalog that follows the first answer. + await rerender() + + expect(api.automations.listScoped).toHaveBeenCalledWith({ selector: { kind: 'orphan' } }) + expect(rows(container, 'automation-row')).toContain('Ambiguous') + const orphanedRow = listedRow(orphaned.id) + expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'history')).toBe(true) + expect(mocks.listPanel?.isActionEnabled(orphanedRow, 'run-now')).toBe(false) + }) + + it('greys out the actions on a row no owner could be captured for', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([automation]) + api.automations.listScoped.mockRejectedValue(new Error('offline')) + + await renderPage() + + // Uncaptured is not blocked: the legacy arm still works, so nothing is greyed. + expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 4445cf21eeb..37ecb45082f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -5,13 +5,12 @@ import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAppStore } from '@/store' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { Automation, + AutomationCreateInput, ExternalAutomationAction, ExternalAutomationJob, ExternalAutomationManager, @@ -20,22 +19,24 @@ import type { AutomationUpdateInput } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + automationOwnerConflictMessage +} from '../../../../shared/automation-owner-conflict' import { getLocalExecutionHostLabel, getRepoExecutionHostId, + getWorktreeExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import type { PreflightStatus } from '../../../../preload/api-types' -import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { Repo } from '../../../../shared/repo-types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id' import { buildAutomationRrule, isValidAutomationCronSchedule, - isValidAutomationSchedule, - tryParseAutomationRrule + isValidAutomationSchedule } from '../../../../shared/automation-schedules' import { canRerunAutomationRun, @@ -48,7 +49,6 @@ import { getAutomationRunOpenTabId, resolveAutomationRunOpenTarget } from './automation-run-open-target' -import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' import { AutomationEditorDialog, @@ -56,7 +56,6 @@ import { type AutomationDraft } from './AutomationEditorDialog' import { - getAutomationSetupDecisionDraftValue, getVisibleAutomationSetupDecision, resolveAutomationSetupDecisionForSave } from './automation-setup-decision' @@ -66,21 +65,18 @@ import { buildAutomationRunContextForRepo } from './automation-run-context' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' +import { useAutomationSourceHostAvailability } from './use-automation-source-host-availability' import { - getRepoBackedProviderAvailability, - type RuntimeProviderPreflightStatus -} from '../task-source-provider-availability' -import type { TaskSourceHostAvailability } from '../task-source-context-summary' + useSelectedAutomationRunHistory, + type SelectedAutomationRunHistoryOutcome +} from './use-selected-automation-run-history' import { - createAutomationForTarget, deleteAutomationForTarget, + type AutomationHostTarget, getAutomationHostTargetFromKey, getAutomationHostTargetKey, - getAutomationListTarget, - getAutomationOwnerTarget, getAutomationTargetFromHostId, - listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, updateAutomationForTarget @@ -90,39 +86,86 @@ import { AUTOMATION_DEFAULT_TIME, buildDraftPrecheck, buildHermesCronSchedule, - formatTimeInput, getDefaultWorktree, parseDraftTime } from './automation-draft-model' -import { - getRepoBackedAutomationSourceContext, - getRuntimeSourceHostAvailability, - type RepoBackedAutomationSourceContext -} from './automation-source-context' import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { isMissingExternalRunsApiError } from './external-automation-display' import { - getExternalAutomationKey, - isMissingExternalRunsApiError -} from './external-automation-display' + externalAutomationActionKey, + externalAutomationJobKey +} from './external-automation-scope-keys' import { buildExternalAutomationListEntries } from './external-automation-list-entries' -import { shouldCloseDetailForLostSelection } from './automation-detail-selection' +import type { ExternalAutomationScope } from './external-automation-scope-client' import { useAutomationListSearch } from './use-automation-list-search' -import { useAutomationListView } from './use-automation-list-view' -import { - EMPTY_AUTOMATION_LIST_FILTER, - nextAutomationListSort, - type AutomationListFilter, - type AutomationListSort -} from './automation-list-view' +import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity' import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' import { AutomationsListPanel } from './AutomationsListPanel' import { AutomationsDetailPane } from './AutomationsDetailPane' import { AutomationsPageSkeleton } from './AutomationsPageSkeleton' +import type { + AutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { + capturedAutomationOwner, + capturedAutomationOwnerKey, + isAutomationActionEnabled, + type AutomationRowAction +} from './automation-captured-owner' +import { useAutomationHostCatalog } from './use-automation-host-catalog' +import { useScopedExternalAutomations } from './use-scoped-external-automations' +import { + externalAutomationScopeEntries, + externalAutomationScopeNotice, + externalManagersListedForEntries, + externalManagersListedForEntry, + resolveExternalAutomationScopeGate +} from './external-automation-scope-gating' +import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts' +import { + automationRuntimePairingRevision, + groupReposByAutomationAuthority +} from './automation-authority-identity' +import { automationCreateHostStableKey } from './automation-create-destination' +import { useAutomationCreateDestination } from './use-automation-create-destination' +import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference' +import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft' +import { createAutomationAtDestination } from './automation-owner-action-runner' +import { + dispatchAutomationDelete, + dispatchAutomationReread, + dispatchAutomationRunNow, + dispatchAutomationUpdate, + toDispatchResult, + type AutomationActionNotice, + type AutomationDispatchResult +} from './automation-row-action-dispatch' +import { + automationRowCatalogRef, + automationWriteChangeEvent +} from './automation-write-invalidation' +import type { AutomationAuthorityChangeReason } from './automation-host-invalidation' +import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' const AGENTS = getAgentCatalog().map((agent) => agent.id) -const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' + +const EMPTY_AUTOMATION_RUNS: readonly AutomationRun[] = [] + +/** Returns the same set when nothing was removed, so an unchanged catalog memo holds. */ +function withoutKey(keys: ReadonlySet, key: string): ReadonlySet { + if (!keys.has(key)) { + return keys + } + const next = new Set(keys) + next.delete(key) + return next +} + export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) @@ -138,20 +181,11 @@ export default function AutomationsPage(): React.JSX.Element { const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const closeAutomationsPage = useAppStore((s) => s.closeAutomationsPage) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const settings = useAppStore((s) => s.settings) - const preflightStatus = useAppStore((s) => s.preflightStatus) - const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) - const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) - const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) - const expectedPreflightContextKey = useAppStore((s) => - localPreflightContextKey(getLocalPreflightContext(s)) - ) const selectedId = useAppStore((s) => s.selectedAutomationId) const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) const pendingAutomationRunNavigation = useAppStore((s) => s.pendingAutomationRunNavigation) @@ -167,13 +201,26 @@ export default function AutomationsPage(): React.JSX.Element { : (enabledAgents[0] ?? AGENTS[0]) const [automations, setAutomations] = useState([]) - const [runs, setRuns] = useState([]) const [automationHostTargetKey, setAutomationHostTargetKey] = useState(null) - const [selectedAutomationRuns, setSelectedAutomationRuns] = useState<{ - automationId: string | null - runs: AutomationRun[] - }>({ automationId: null, runs: [] }) - const [externalManagers, setExternalManagers] = useState([]) + const [selectedAutomationRuns, setSelectedAutomationRuns] = + useState({ + automationId: null, + rowKey: null, + ownerKey: null, + runs: [], + notice: null + }) + // Bumped by the run-history Retry; the read is otherwise keyed only by the row. + const [runHistoryReloadToken, setRunHistoryReloadToken] = useState(0) + // Why a set of authority keys: a failed list is a fact about the authority that + // answered, and the catalog needs it per authority to mark its hosts unloaded. + const [failedAuthorityKeys, setFailedAuthorityKeys] = useState>( + () => new Set() + ) + const [ownerActionNotice, setOwnerActionNotice] = useState(null) + // Separate from the page notice: while the editor is open it covers the page, + // so a refusal posted there is a save that visibly did nothing at all. + const [editorNotice, setEditorNotice] = useState(null) const [externalActionKey, setExternalActionKey] = useState(null) const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState>( () => new Set() @@ -181,38 +228,34 @@ export default function AutomationsPage(): React.JSX.Element { const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [listSearchQuery, setListSearchQuery] = useState('') - const [listFilter, setListFilter] = useState(EMPTY_AUTOMATION_LIST_FILTER) - const [listSort, setListSort] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) + // Held beside the id: a save fences on the row the user opened, and under All + // hosts that id alone names two rows. + const [editingRowKey, setEditingRowKey] = useState(null) const [relativeNow, setRelativeNow] = useState(Date.now()) const [activePaneTab, setActivePaneTab] = useState('overview') const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState( null ) + // Held next to the store's automation id rather than in it: the id is what + // other pages navigate by, and only this side knows which host's copy is shown. + const [selectedRowKey, setSelectedRowKey] = useState(null) const [selectedExternalKey, setSelectedExternalKey] = useState(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState(null) // Why: list is the primary surface; detail is a full-page drill-in, not a side pane. const [isDetailOpen, setIsDetailOpen] = useState(false) const selectedExternalKeyRef = useRef(null) - const isDetailOpenRef = useRef(false) // Keep async refresh/delete handlers reading the latest selection without render-time mutation. useEffect(() => { selectedExternalKeyRef.current = selectedExternalKey }, [selectedExternalKey]) - useEffect(() => { - isDetailOpenRef.current = isDetailOpen - }, [isDetailOpen]) - const runtimePreflightMountedRef = useRef(true) - const runtimePreflightRequestedHostIdsRef = useRef>(new Set()) - const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< - ReadonlyMap - >(() => new Map()) const selectAutomationId = useCallback( (automationId: string | null): void => { setSelectedAutomationRunPageId(null) + setSelectedRowKey(null) setSelectedId(automationId) }, [setSelectedId] @@ -222,10 +265,12 @@ export default function AutomationsPage(): React.JSX.Element { setSelectedExternalKey(externalKey) }, []) const [draftAtOpen, setDraftAtOpen] = useState(null) - const [deleteTarget, setDeleteTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) const [externalDeleteTarget, setExternalDeleteTarget] = useState<{ manager: ExternalAutomationManager job: ExternalAutomationJob + /** Captured with the row, so the confirmed delete cannot land on another host. */ + scope: ExternalAutomationScope } | null>(null) useContextualTour( 'automations', @@ -235,6 +280,9 @@ export default function AutomationsPage(): React.JSX.Element { const [editingExternalTarget, setEditingExternalTarget] = useState<{ manager: ExternalAutomationManager job: ExternalAutomationJob + // Captured from the row that opened the dialog: the manager ID alone cannot + // name an authority, so a re-lookup could save to the wrong host. + scope: ExternalAutomationScope } | null>(null) const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) @@ -242,7 +290,6 @@ export default function AutomationsPage(): React.JSX.Element { // Why: both dialogs stay mounted, so a shared ref would let one dialog's // unmount clear the focus target the other still needs. const externalDeleteConfirmButtonRef = useRef(null) - const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) const setupDecisionPolicyDefaultRef = useRef(undefined) @@ -274,66 +321,113 @@ export default function AutomationsPage(): React.JSX.Element { scheduleWarning: null }) - const externalAutomationEntries = useMemo( - () => buildExternalAutomationListEntries(externalManagers), - [externalManagers] + const hostCatalog = useAutomationHostCatalog({ failedAuthorityKeys }) + // Probing follows the selection, not the catalog: a Local selection must not + // reach out to every SSH host the user happens to have registered. + const externalScopeEntries = useMemo( + () => externalAutomationScopeEntries(hostCatalog.entries, hostCatalog.resolution), + [hostCatalog.entries, hostCatalog.resolution] ) - const { - isListSearchQueryTooLarge, - isListSearchActive, - filteredAutomations, - filteredExternalAutomationEntries, - hasListItems - } = useAutomationListSearch({ - listSearchQuery, - automations, - externalAutomationEntries, - repoMap, - selectedId, - selectedExternalKey, - selectAutomationId, - selectExternalKey - }) - const { - visibleItems, - isListFilterActive, - hasVisibleListItems: hasFilteredListItems - } = useAutomationListView({ - automations: filteredAutomations, - externalEntries: filteredExternalAutomationEntries, - runs, - filter: listFilter, - sort: listSort, - selectedId, - selectedExternalKey, - selectAutomationId, - selectExternalKey + const scopedExternal = useScopedExternalAutomations({ + catalogEntries: hostCatalog.entries, + scopeEntries: externalScopeEntries }) + // Held apart from the view object, which is a fresh literal each render and + // would refetch the runs table on every render of this page. + const fetchScopedExternalRuns = scopedExternal.fetchRuns + // Why the cache wins once it has answered: only its rows carry the host each + // record came from, which is what the picker, the groups, and every fenced + // action key on. Until then the unscoped list is all the page has, and it has + // no host to qualify its rows with. + const unscopedRows = useMemo(() => unscopedAutomationListRows(automations), [automations]) + const visibleRows = hostCatalog.rows.answered ? hostCatalog.rows.rows : unscopedRows + // Picking a row records both: the key names the host's copy, the id is what + // the rest of the page (and other pages) still address the record by. + const selectAutomationRow = useCallback( + (rowKey: string | null): void => { + const row = rowKey === null ? null : visibleRows.find((candidate) => candidate.key === rowKey) + setSelectedAutomationRunPageId(null) + setSelectedRowKey(row?.key ?? null) + setSelectedId(row?.automation.id ?? null) + }, + [setSelectedId, visibleRows] + ) + const capturedAutomationOwners = hostCatalog.rows.capturedOwners + const externalAutomationEntries = useMemo( + () => buildExternalAutomationListEntries(scopedExternal.managers), + [scopedExternal.managers] + ) const selectedExternal = externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ?? - (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) - const selected = + (visibleRows.length === 0 ? (externalAutomationEntries[0] ?? null) : null) + // The row key decides which of two same-id rows is selected; the stored id + // still decides which record, so a selection arriving from another page (which + // only carries an id) still lands somewhere. + const selectedRow = selectedExternal === null ? selectedId - ? (automations.find((automation) => automation.id === selectedId) ?? null) - : (automations[0] ?? null) + ? (visibleRows.find( + (row) => row.key === selectedRowKey && row.automation.id === selectedId + ) ?? + visibleRows.find((row) => row.automation.id === selectedId) ?? + null) + : (visibleRows[0] ?? null) : null - const runsWithWorkspaceNames = useMemo( - () => - runs.map((run) => { - if (!run.workspaceId || run.workspaceDisplayName?.trim()) { - return run - } - const displayName = - worktreeMap.get(run.workspaceId)?.displayName ?? - workspaceNameCacheRef.current.get(run.workspaceId) ?? - getWorktreePathBasenameFromId(run.workspaceId) - const trimmedDisplayName = displayName?.trim() - return trimmedDisplayName ? { ...run, workspaceDisplayName: trimmedDisplayName } : run - }), - [runs, worktreeMap] - ) + const selected = selectedRow?.automation ?? null + useEffect(() => { + if (!isDetailOpen || pendingAutomationRunNavigation) { + return + } + const hasSelectedLocal = selectedRowKey + ? visibleRows.some((row) => row.key === selectedRowKey) + : selectedId !== null && selectedRow !== null + const hasSelectedExternal = + selectedExternalKey !== null && + externalAutomationEntries.some((entry) => entry.key === selectedExternalKey) + if (hasSelectedLocal || hasSelectedExternal) { + return + } + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + }, [ + externalAutomationEntries, + isDetailOpen, + pendingAutomationRunNavigation, + selectedExternalKey, + selectedId, + selectedRow, + selectedRowKey, + visibleRows + ]) + // The detail pane renders `selectedRow.automation`, so the record it echoes back + // names no row; the page already holds the one the user is looking at. + const onSelectedRow = (act: (row: AutomationListRow) => void): void => { + if (selectedRow) { + act(selectedRow) + } + } + + const { + isListSearchQueryTooLarge, + filteredRows, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems, + searchCounts + } = useAutomationListSearch({ + listSearchQuery, + rows: visibleRows, + externalAutomationEntries, + repoMap, + worktreeMap, + selectedRowKey: selectedRow?.key ?? null, + selectedExternalKey, + selectAutomationRow, + selectExternalKey + }) const selectedAutomationRunsWithWorkspaceNames = useMemo( () => selectedAutomationRuns.runs.map((run) => { @@ -419,11 +513,22 @@ export default function AutomationsPage(): React.JSX.Element { const markSetupDecisionTouched = useCallback((): void => { setupDecisionTouchedRef.current = true }, []) - // Why: keep the detail tab scoped even while the selected-run fetch catches up. - const selectedRunsSource = - selected && selectedAutomationRuns.automationId === selected.id - ? selectedAutomationRunsWithWorkspaceNames - : runsWithWorkspaceNames + // Row-qualified *and* incarnation-qualified: the row key says whose copy the + // history was read for, the owner key says whether that host is still the + // incarnation that answered. + const selectedRunsMatchSelection = + selectedRow !== null && + selectedAutomationRuns.rowKey === selectedRow.key && + selectedAutomationRuns.ownerKey === + capturedAutomationOwnerKey(capturedAutomationOwner(capturedAutomationOwners, selectedRow.key)) + // Empty rather than another row's history while the scoped fetch is in flight: + // the page no longer holds every run, and showing the previous row's is worse + // than showing none. + const selectedRunsSource = selectedRunsMatchSelection + ? selectedAutomationRunsWithWorkspaceNames + : EMPTY_AUTOMATION_RUNS + // Why: an unanswered history is not an empty one, so the pane states the refusal instead. + const selectedRunsNotice = selectedRunsMatchSelection ? selectedAutomationRuns.notice : null const selectedRuns = useMemo( () => (selected ? selectedRunsSource.filter((run) => run.automationId === selected.id) : []), [selected, selectedRunsSource] @@ -439,6 +544,102 @@ export default function AutomationsPage(): React.JSX.Element { () => getAutomationHostTargetFromKey(automationHostTargetKey), [automationHostTargetKey] ) + // The authority behind the *selected host*, used for orphan actions and for + // stating a create destination. Owned rows never read it: their owner already + // names an authority. Under All hosts no host is selected, so this is the + // desktop — the client's own authority, never a guess at which host is meant. + const automationAuthority = useMemo((): AutomationAuthorityRef => { + const selectedAuthority = hostCatalog.resolution.entry?.stableRef.authority + if (selectedAuthority?.kind !== 'runtime') { + return { kind: 'desktop' } + } + const environmentId = selectedAuthority.environmentId + return { + kind: 'runtime', + environmentId, + pairingRevision: automationRuntimePairingRevision(runtimeEnvironments, environmentId) + } + }, [hostCatalog.resolution.entry, runtimeEnvironments]) + // Scoped per owning authority: a repo ID is unique only inside one, so the + // flat map cannot say whether *this* authority holds the project. + const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) + const activeWorkspaceHostStableKey = useMemo(() => { + const worktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null + return worktree + ? automationCreateHostStableKey( + getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId)) + ) + : null + }, [activeWorktreeId, repoMap, worktreeMap]) + const createDestination = useAutomationCreateDestination({ + open: createOpen && editingAutomationId === null && createTarget === 'orca', + catalog: hostCatalog.catalog, + entries: hostCatalog.entries, + // Non-null only for a concrete host filter, which constrains the destination. + filterStableKey: hostCatalog.resolution.entry?.stableKey ?? null, + activeWorkspaceStableKey: activeWorkspaceHostStableKey, + repoTables + }) + // The row's own host, from its captured owner. A page-level target cannot + // speak for a list spanning authorities, and the legacy arm below it is only + // ever reached by rows the desktop's unscoped list produced. + const automationHostTargetFor = useCallback( + (row: AutomationListRow): AutomationHostTarget | null => { + const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner + if (owner?.authority.kind === 'runtime') { + return { kind: 'environment', environmentId: owner.authority.environmentId } + } + return owner ? { kind: 'local' } : automationHostTarget + }, + [automationHostTarget, capturedAutomationOwners] + ) + const automationDispatchContext = useMemo( + () => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }), + [automationAuthority, capturedAutomationOwners] + ) + const notifyAuthorityChange = hostCatalog.notifyAuthorityChange + // The list renders the per-host cache, so a write is only visible once that + // host is refetched. The authority publishes the same event, but a round trip + // later — and it cannot name a host the cache has no entry for, which is + // precisely the host a create lands on. + const invalidateWrittenHost = useCallback( + (ref: StableAutomationCatalogRef | null, reason: AutomationAuthorityChangeReason): void => { + notifyAuthorityChange(automationWriteChangeEvent(ref, automationAuthority, reason)) + }, + [automationAuthority, notifyAuthorityChange] + ) + const invalidateRowHost = useCallback( + (rowKey: string | null, reason: AutomationAuthorityChangeReason): void => { + const captured = capturedAutomationOwner(capturedAutomationOwners, rowKey) + invalidateWrittenHost(automationRowCatalogRef(captured, automationAuthority), reason) + }, + [automationAuthority, capturedAutomationOwners, invalidateWrittenHost] + ) + const isAutomationRowActionEnabled = useCallback( + (row: AutomationListRow, action: AutomationRowAction): boolean => + isAutomationActionEnabled(capturedAutomationOwner(capturedAutomationOwners, row.key), action), + [capturedAutomationOwners] + ) + const externalScopeGate = useMemo(() => { + if (hostCatalog.resolution.effective.kind !== 'all') { + return resolveExternalAutomationScopeGate(hostCatalog.resolution.entry) + } + // One scope-limited host makes the combined list incomplete, and its own + // gate carries the more specific reason; a null entry means no claim. + return ( + hostCatalog.entries + .map((entry) => resolveExternalAutomationScopeGate(entry)) + .find((gate) => gate.status === 'not-listed') ?? resolveExternalAutomationScopeGate(null) + ) + }, [hostCatalog.entries, hostCatalog.resolution]) + const externalManagersListed = + hostCatalog.resolution.effective.kind === 'all' + ? externalManagersListedForEntries(hostCatalog.entries) + : externalManagersListedForEntry(hostCatalog.resolution.entry) + const externalManagersUncheckedNotice = useMemo( + () => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries), + [scopedExternal.failures, hostCatalog.entries] + ) useEffect(() => { for (const [workspaceId, worktree] of worktreeMap) { @@ -492,6 +693,18 @@ export default function AutomationsPage(): React.JSX.Element { setPendingAutomationRunNavigation(null) return } + if ( + selectedAutomationRuns.notice && + selectedAutomationRuns.automationId === pending.automationId + ) { + // The host refused the history: the runs pane states that and offers its own + // recovery, so the navigation must not sit pending waiting on runs never coming. + setIsDetailOpen(true) + setActivePaneTab('runs') + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + return + } if (selectedAutomationRuns.automationId !== pending.automationId) { return } @@ -519,6 +732,7 @@ export default function AutomationsPage(): React.JSX.Element { selectExternalKey, selectedAutomationRuns.automationId, selectedExternalKey, + selectedAutomationRuns.notice, selectedId, selectedRuns, setPendingAutomationRunNavigation, @@ -573,141 +787,20 @@ export default function AutomationsPage(): React.JSX.Element { }) const isSelectedAutomationRunPageRerunPending = selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id) - const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey - const repoBackedAutomationSourceContexts = useMemo( - () => - automations - .map((automation) => getRepoBackedAutomationSourceContext(automation)) - .filter((context): context is RepoBackedAutomationSourceContext => context !== null), - [automations] - ) - const runtimeAutomationSourceHostIds = useMemo(() => { - const hostIds = new Set() - for (const context of repoBackedAutomationSourceContexts) { - const parsed = parseExecutionHostId(context.hostId) - if (parsed?.kind !== 'runtime') { - continue - } - const hostAvailability = getRuntimeSourceHostAvailability( - context, - runtimeStatusByEnvironmentId - ) - if (hostAvailability) { - continue - } - hostIds.add(parsed.id) - } - return [...hostIds].sort() - }, [repoBackedAutomationSourceContexts, runtimeStatusByEnvironmentId]) - useEffect( - () => () => { - runtimePreflightMountedRef.current = false - }, - [] - ) - useEffect(() => { - if (!preflightStatusCurrent || !preflightStatusChecked) { - void refreshPreflightStatus() - } - }, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus]) - useEffect(() => { - const unrequestedHostIds = runtimeAutomationSourceHostIds.filter( - (hostId) => !runtimePreflightRequestedHostIdsRef.current.has(hostId) - ) - if (unrequestedHostIds.length === 0) { - return - } - setRuntimePreflightStatusByHostId((current) => { - const next = new Map(current) - for (const hostId of unrequestedHostIds) { - next.set(hostId, { checked: false, status: null }) - } - return next - }) - for (const hostId of unrequestedHostIds) { - runtimePreflightRequestedHostIdsRef.current.add(hostId) - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind !== 'runtime') { - continue - } - // Why: automation sources can be owned by a different remote server than - // the run target; provider auth/tooling must be checked on the source host. - void callRuntimeRpc( - { kind: 'environment', environmentId: parsed.environmentId }, - 'preflight.check', - undefined, - { timeoutMs: 15_000 } - ) - .then((status) => { - if (!runtimePreflightMountedRef.current) { - return - } - setRuntimePreflightStatusByHostId((current) => { - const next = new Map(current) - next.set(hostId, { checked: true, status }) - return next - }) - }) - .catch(() => { - if (!runtimePreflightMountedRef.current) { - return - } - setRuntimePreflightStatusByHostId((current) => { - const next = new Map(current) - next.set(hostId, { checked: true, status: null }) - return next - }) - }) - } - }, [runtimeAutomationSourceHostIds]) - const automationSourceHostAvailabilityById = useMemo(() => { - const availabilityById = new Map() - for (const automation of automations) { - const context = getRepoBackedAutomationSourceContext(automation) - if (!context) { - continue - } - const hostAvailability = getRuntimeSourceHostAvailability( - context, - runtimeStatusByEnvironmentId - ) - const providerAvailability = getRepoBackedProviderAvailability({ - provider: context.provider, - contexts: [context], - preflightStatus, - preflightReady: preflightStatusCurrent && preflightStatusChecked, - runtimePreflightStatusByHostId - }) - const availability = [ - ...(hostAvailability ? [hostAvailability] : []), - ...providerAvailability - ] - if (availability.length > 0) { - availabilityById.set(automation.id, availability) - } - } - return availabilityById - }, [ - automations, - preflightStatus, - preflightStatusChecked, - preflightStatusCurrent, - runtimePreflightStatusByHostId, - runtimeStatusByEnvironmentId - ]) + const automationSourceHostAvailabilityByRowKey = useAutomationSourceHostAvailability(visibleRows) const selectedRepo = selected ? (repoMap.get(getAutomationRunRepoId(selected)) ?? null) : null const selectedWorktree = selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null - const selectedRunNowAvailability = selected + const selectedRunNowAvailability = selectedRow ? getAutomationTargetAvailability({ - automation: selected, + automation: selectedRow.automation, repo: selectedRepo, workspace: selectedWorktree, projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) + automationHostTarget: automationHostTargetFor(selectedRow), + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(selectedRow.key) }) : null const canSaveDraft = @@ -767,63 +860,42 @@ export default function AutomationsPage(): React.JSX.Element { } }, [activeWorktreeId, repoMap, repos, worktreeMap, worktreesByRepo]) + const reloadExternalManagers = scopedExternal.reload + const refresh = useCallback(async () => { setIsLoading(true) const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation - const automationHostTarget = pendingNavigation + // The desktop unless navigation named a host: this arm exists for rows the + // per-host reads have not answered for, and the client's own authority is + // the only one it can address without guessing which server is meant. + const automationHostTarget: AutomationHostTarget = pendingNavigation ? getAutomationTargetFromHostId(pendingNavigation.hostId) - : getAutomationListTarget(settings) + : { kind: 'local' } + const authorityKey = automationAuthorityCatalogKey( + automationHostTarget.kind === 'environment' + ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } + : { kind: 'desktop' } + ) try { - const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ + const [nextAutomations] = await Promise.all([ listAutomationsForTarget(automationHostTarget), - listAutomationRunsForTarget(automationHostTarget), - window.api.automations.listExternalManagers() + // Managers are per host and failures are per provider, so this settles + // on its own and must not be able to fail the automation list with it. + reloadExternalManagers().catch(() => undefined) ]) - const currentSelectedId = useAppStore.getState().selectedAutomationId - const hasCurrentSelection = nextAutomations.some( - (automation) => automation.id === currentSelectedId - ) - let nextSelectedId: string | null - if (hasCurrentSelection) { - nextSelectedId = currentSelectedId - } else if (pendingNavigation) { - nextSelectedId = pendingNavigation.automationId - } else { - nextSelectedId = nextAutomations[0]?.id ?? null - } - const nextSelectedRuns = nextSelectedId - ? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId) - : [] + // Selection and run history are deliberately not written here: this call + // addressed one authority, and the selected row may belong to another. setAutomations(nextAutomations) - setRuns(nextRuns) setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) - setSelectedAutomationRuns({ - automationId: nextSelectedId, - runs: nextSelectedRuns - }) - setExternalManagers(nextExternalManagers) - if (!hasCurrentSelection && !pendingNavigation) { - selectAutomationId(nextAutomations[0]?.id ?? null) - if ( - shouldCloseDetailForLostSelection({ - isDetailOpen: isDetailOpenRef.current, - hasPendingNavigation: false, - isSelectedAutomationInNextList: false, - isSelectedExternalInNextList: buildExternalAutomationListEntries( - nextExternalManagers - ).some((entry) => entry.key === selectedExternalKeyRef.current) - }) - ) { - setIsDetailOpen(false) - setSelectedAutomationRunPageId(null) - setSelectedExternalRunPage(null) - setActivePaneTab('overview') - } - } + setFailedAuthorityKeys((current) => withoutKey(current, authorityKey)) + } catch { + // Why not a toast and not a rethrow: the list keeps whatever it had, and + // the host's own status row is where the failure and its Retry belong. + setFailedAuthorityKeys((current) => new Set(current).add(authorityKey)) } finally { setIsLoading(false) } - }, [selectAutomationId, settings]) + }, [reloadExternalManagers]) useEffect(() => { if (!pendingAutomationRunNavigation || isLoading) { @@ -866,29 +938,14 @@ export default function AutomationsPage(): React.JSX.Element { }) }, []) - useEffect(() => { - const automationId = selected?.id ?? null - if (!automationId) { - setSelectedAutomationRuns({ automationId: null, runs: [] }) - return - } - let cancelled = false - const target = - pendingAutomationRunNavigation?.automationId === automationId && - pendingAutomationRunNavigation.hostId - ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) - : selected - ? getAutomationOwnerTarget(selected, automationHostTarget) - : getAutomationListTarget(settings) - void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { - if (!cancelled) { - setSelectedAutomationRuns({ automationId, runs: nextRuns }) - } - }) - return () => { - cancelled = true - } - }, [automationHostTarget, pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) + useSelectedAutomationRunHistory({ + selected: selectedRow, + context: automationDispatchContext, + legacyTarget: automationHostTargetFor, + navigation: pendingAutomationRunNavigation, + reloadToken: runHistoryReloadToken, + onSettled: setSelectedAutomationRuns + }) useEffect(() => { const onAutomationsChanged = (): void => { @@ -912,56 +969,6 @@ export default function AutomationsPage(): React.JSX.Element { } }, [refresh]) - useEffect(() => { - const inFlight = completionInFlightRef.current - const completedRuns = runs.filter((run) => { - if (run.status !== 'dispatched' || !run.terminalPaneKey) { - return false - } - if (inFlight.has(run.id)) { - return false - } - const dispatchedAt = run.dispatchedAt ?? null - if (dispatchedAt === null) { - return false - } - return hasAutomationRunCompletionEvidence({ - run, - dispatchedAt, - agentStatusByPaneKey, - retainedAgentsByPaneKey - }) - }) - if (completedRuns.length === 0) { - return - } - for (const run of completedRuns) { - inFlight.add(run.id) - } - void Promise.all( - completedRuns.map((run) => - window.api.automations.markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - terminalPaneKey: run.terminalPaneKey, - terminalPtyId: run.terminalPtyId, - error: null - }) - ) - ) - .then(() => refresh()) - .catch((error) => { - console.error('[automations] failed to mark completed dispatch result:', error) - }) - .finally(() => { - for (const run of completedRuns) { - inFlight.delete(run.id) - } - }) - }, [agentStatusByPaneKey, retainedAgentsByPaneKey, refresh, runs]) - useEffect(() => { if (!draft.projectId) { const target = getDefaultTarget() @@ -1102,49 +1109,31 @@ export default function AutomationsPage(): React.JSX.Element { setCreateOpen(true) } - const openEditDialog = async (automation: Automation): Promise => { + const openEditDialog = async (row: AutomationListRow): Promise => { const requestId = (editRequestRef.current += 1) setEditingExternalTarget(null) setCreateTarget('orca') - let latest = automation - try { - latest = - (await window.api.automations.list()).find((entry) => entry.id === automation.id) ?? - automation - } catch { - latest = automation + const automationId = row.automation.id + // Why: hydrate inside the row's own captured scope, not from whatever the + // ambient authority happens to hold under the same automation ID. + const reread = await dispatchAutomationReread( + automationDispatchContext, + { rowKey: row.key, automationId }, + async () => + (await window.api.automations.list()).find((entry) => entry.id === automationId) ?? null + ) + if (!reread.ok && reread.notice.severity === 'owner') { + setOwnerActionNotice(reread.notice) + return } + // A failed re-read still opens the form on the copy already on screen. + const latest = (reread.ok ? reread.value : null) ?? row.automation if (requestId !== editRequestRef.current) { return } - const schedule = tryParseAutomationRrule(latest.rrule) - const hasCustomSchedule = !schedule && isValidAutomationSchedule(latest.rrule) setEditingAutomationId(latest.id) - const nextDraft: AutomationDraft = { - name: latest.name, - prompt: latest.prompt, - agentId: latest.agentId, - projectId: getAutomationRunRepoId(latest), - workspaceMode: latest.workspaceMode, - workspaceId: latest.workspaceId ?? '', - baseBranch: latest.baseBranch ?? '', - setupDecision: getAutomationSetupDecisionDraftValue({ - workspaceMode: latest.workspaceMode, - persistedSetupDecision: latest.setupDecision - }), - reuseSession: latest.workspaceMode === 'existing' && latest.reuseSession, - precheckCommand: latest.precheck?.command ?? '', - precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), - preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), - time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, - dayOfWeek: String(schedule?.dayOfWeek ?? 1), - customSchedule: hasCustomSchedule ? latest.rrule : '', - missedRunGraceMinutes: String(latest.missedRunGraceMinutes), - scheduleWarning: - schedule || hasCustomSchedule - ? null - : 'This automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' - } + setEditingRowKey(row.key) + const nextDraft = buildAutomationEditDraft(latest) setDraft(nextDraft) setDraftAtOpen(nextDraft) setCreateOpen(true) @@ -1152,11 +1141,10 @@ export default function AutomationsPage(): React.JSX.Element { const openEditExternalDialog = ( manager: ExternalAutomationManager, - job: ExternalAutomationJob + job: ExternalAutomationJob, + scope: ExternalAutomationScope ): void => { editRequestRef.current += 1 - const rawSchedule = job.rawSchedule?.trim() ?? '' - const hasCustomSchedule = isValidAutomationCronSchedule(rawSchedule) const targetWorktree = Object.values(worktreesByRepo) .flat() @@ -1169,31 +1157,13 @@ export default function AutomationsPage(): React.JSX.Element { return repoTargetMatches && job.workdir !== null && worktree.path === job.workdir }) ?? null const fallbackTarget = getDefaultTarget() - const projectId = targetWorktree?.repoId ?? fallbackTarget.projectId - const workspaceId = targetWorktree?.id ?? fallbackTarget.workspaceId - const nextDraft: AutomationDraft = { - name: job.name, - prompt: job.prompt ?? job.promptPreview, - agentId: 'hermes', - projectId, - workspaceMode: 'existing', - workspaceId, - baseBranch: '', - setupDecision: undefined, - reuseSession: false, - precheckCommand: '', - precheckTimeoutSeconds: '60', - preset: hasCustomSchedule ? 'custom' : 'weekdays', - time: AUTOMATION_DEFAULT_TIME, - dayOfWeek: '1', - customSchedule: hasCustomSchedule ? rawSchedule : '', - missedRunGraceMinutes: '720', - scheduleWarning: hasCustomSchedule - ? null - : 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' - } + const nextDraft = buildExternalAutomationEditDraft(job, { + projectId: targetWorktree?.repoId ?? fallbackTarget.projectId, + workspaceId: targetWorktree?.id ?? fallbackTarget.workspaceId + }) setEditingAutomationId(null) - setEditingExternalTarget({ manager, job }) + setEditingRowKey(null) + setEditingExternalTarget({ manager, job, scope }) setCreateTarget('hermes') setDraft(nextDraft) setDraftAtOpen(nextDraft) @@ -1230,6 +1200,7 @@ export default function AutomationsPage(): React.JSX.Element { ) const saveAutomation = async (): Promise => { + setEditorNotice(null) const { hour, minute } = parseDraftTime(draft.time) const isHermesSave = editingAutomationId === null && (createTarget === 'hermes' || editingExternalTarget !== null) @@ -1306,14 +1277,16 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const target = - editingExternalTarget?.manager.target ?? - (repo.connectionId - ? { type: 'ssh' as const, connectionId: repo.connectionId } - : { type: 'local' as const }) + // The scope the manager was listed under, never one re-derived from the + // repo at save time — that is how an edit lands on the wrong host. + const scope = editingExternalTarget + ? editingExternalTarget.scope + : scopedExternal.createScope(repo.connectionId ?? null) const repoTargetMatches = - target.type === 'local' ? !repo.connectionId : repo.connectionId === target.connectionId - if (!repoTargetMatches) { + scope?.owner.selector.kind === 'ssh' + ? repo.connectionId === scope.owner.selector.targetId + : !repo.connectionId + if (!scope || !repoTargetMatches) { toast.error( translate( 'auto.components.automations.AutomationsPage.e431bb85d4', @@ -1322,34 +1295,27 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const schedule = buildHermesCronSchedule(draft) - const managerId = - editingExternalTarget?.manager.id ?? - (target.type === 'ssh' ? `hermes:ssh:${target.connectionId}` : 'hermes:local') - const input = { - managerId, - provider: 'hermes' as const, - target, - name: draft.name, - prompt: draft.prompt, - schedule, - workdir: selectedWorktree.path - } - await (editingExternalTarget - ? window.api.automations.updateExternal({ - ...input, - jobId: editingExternalTarget.job.id - }) - : window.api.automations.createExternal(input)) + await scopedExternal.saveExternalAutomation( + scope, + { + name: draft.name, + prompt: draft.prompt, + schedule: buildHermesCronSchedule(draft), + workdir: selectedWorktree.path + }, + editingExternalTarget?.job.id ?? null + ) if (!editingExternalTarget) { useAppStore.getState().recordFeatureInteraction('automation-created') } await refresh() setCreateOpen(false) setEditingExternalTarget(null) + // Same helper and same captured scope the row's key was built from, so the + // edited automation stays selected instead of falling back to the first row. selectExternalKey( editingExternalTarget - ? getExternalAutomationKey(editingExternalTarget.manager, editingExternalTarget.job) + ? externalAutomationJobKey(editingExternalTarget.scope, editingExternalTarget.job.id) : null ) toast.success( @@ -1421,14 +1387,21 @@ export default function AutomationsPage(): React.JSX.Element { ? (automations.find((automation) => automation.id === editingAutomationId) ?? null) : null if (editingAutomationId) { - try { - currentAutomation = - (await listAutomationsForTarget(getAutomationListTarget(settings))).find( + // Why: the conflict check reads the row's own scope, and the legacy read + // under it addresses the desktop rather than whichever host was ambient. + const reread = await dispatchAutomationReread( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, + async () => + (await listAutomationsForTarget(automationHostTarget ?? { kind: 'local' })).find( (automation) => automation.id === editingAutomationId - ) ?? currentAutomation - } catch { - // Keep the in-memory automation as a fallback if the refresh fails. + ) ?? null + ) + if (!reread.ok && reread.notice.severity === 'owner') { + setEditorNotice(reread.notice) + return } + currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation } const updates: AutomationUpdateInput = { name: draft.name, @@ -1450,14 +1423,25 @@ export default function AutomationsPage(): React.JSX.Element { updates.rrule = rrule updates.dtstart = now } - const automation = editingAutomationId - ? currentAutomation - ? await updateAutomationForTarget(currentAutomation, updates, automationHostTarget) - : await window.api.automations.update({ - id: editingAutomationId, - updates - }) - : await createAutomationForTarget({ + const editSource = currentAutomation + const saved = editingAutomationId + ? await dispatchAutomationUpdate( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, + updates, + () => { + // Nothing names a host: no captured owner and no record to read one + // from, so the save is refused rather than sent unfenced. + if (!editSource) { + throw new Error( + automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + ) + } + return updateAutomationForTarget(editSource, updates, automationHostTarget) + }, + 'save' + ) + : await createDraftAutomation({ name: draft.name, prompt: draft.prompt, precheck, @@ -1474,7 +1458,16 @@ export default function AutomationsPage(): React.JSX.Element { dtstart: now, missedRunGraceMinutes }) - if (!editingAutomationId) { + if (!saved.ok) { + setEditorNotice(saved.notice) + return + } + const automation = saved.value + // Create invalidates its stated destination inside `createDraftAutomation`, + // which is the only place that still holds it. + if (editingAutomationId) { + invalidateRowHost(editingRowKey, 'definition') + } else { await hydratePersistedUIState() } setAutomations((current) => { @@ -1513,64 +1506,77 @@ export default function AutomationsPage(): React.JSX.Element { } } - const toggleAutomation = async (automation: Automation): Promise => { - await updateAutomationForTarget( - automation, - { enabled: !automation.enabled }, - automationHostTarget + /** Creation states its destination and re-checks it at submit; it never infers one. */ + const createDraftAutomation = async ( + input: AutomationCreateInput + ): Promise> => { + const checked = createDestination.check(input.projectId) + if (!checked.ok) { + return { ok: false, notice: checked.notice } + } + const result = toDispatchResult( + await createAutomationAtDestination( + checked.destination.authority, + input, + checked.destination.destination + ) ) - await refresh() + if (result.ok) { + invalidateWrittenHost(checked.destination.entry.stableRef, 'definition') + } + return result } - const deleteAutomation = async (automation: Automation): Promise => { - await deleteAutomationForTarget(automation, automationHostTarget) - if (useAppStore.getState().selectedAutomationId === automation.id) { - selectAutomationId(null) - setIsDetailOpen(false) - setSelectedAutomationRunPageId(null) - setActivePaneTab('overview') + const toggleAutomation = async (row: AutomationListRow): Promise => { + const automation = row.automation + const result = await dispatchAutomationUpdate( + automationDispatchContext, + { rowKey: row.key, automationId: automation.id }, + { enabled: !automation.enabled }, + () => + updateAutomationForTarget( + automation, + { enabled: !automation.enabled }, + automationHostTargetFor(row) + ) + ) + setOwnerActionNotice(result.ok ? null : result.notice) + if (result.ok) { + invalidateRowHost(row.key, 'definition') } await refresh() } - const persistDeleteAutomationPreference = (): void => { - void updateSettings({ skipDeleteAutomationConfirm: true }) - toast.success( - translate( - 'auto.components.automations.AutomationsPage.690b94da54', - "We'll skip this confirmation next time." - ), - { - description: translate( - 'auto.components.automations.AutomationsPage.d2a01b0b6f', - 'You can change this in Settings.' - ), - duration: 8000, - action: { - label: translate( - 'auto.components.automations.AutomationsPage.8a3226f172', - 'Open Settings' - ), - onClick: () => { - openSettingsPage() - openSettingsTarget({ - pane: 'general', - repoId: null, - sectionId: 'general-skip-delete-automation-confirm' - }) - } - } - } + const deleteAutomation = async (row: AutomationListRow): Promise => { + const automation = row.automation + const result = await dispatchAutomationDelete( + automationDispatchContext, + { rowKey: row.key, automationId: automation.id }, + () => deleteAutomationForTarget(automation, automationHostTargetFor(row)) ) + setOwnerActionNotice(result.ok ? null : result.notice) + if (result.ok) { + if (selectedRowKey === row.key) { + selectAutomationId(null) + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setActivePaneTab('overview') + } + invalidateRowHost(row.key, 'definition') + } + await refresh() } - const requestDeleteAutomation = (automation: Automation): void => { + const persistDeleteAutomationPreference = (): void => + persistSkipDeleteAutomationConfirm({ updateSettings, openSettingsPage, openSettingsTarget }) + + const requestDeleteAutomation = (row: AutomationListRow): void => { if (settings?.skipDeleteAutomationConfirm) { - void deleteAutomation(automation) + void deleteAutomation(row) return } setDontAskDeleteAgain(false) - setDeleteTarget(automation) + setDeleteTarget(row) } const confirmDeleteAutomation = async (): Promise => { @@ -1586,11 +1592,13 @@ export default function AutomationsPage(): React.JSX.Element { await deleteAutomation(target) } - const runNow = async (automation: Automation): Promise => { + const runNow = async (row: AutomationListRow): Promise => { + const automation = row.automation const repo = repoMap.get(getAutomationRunRepoId(automation)) ?? null const workspace = automation.workspaceId ? (worktreeMap.get(automation.workspaceId) ?? null) : null + const rowHostTarget = automationHostTargetFor(row) const availability = getAutomationTargetAvailability({ automation, repo, @@ -1598,15 +1606,27 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + automationHostTarget: rowHostTarget, + sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key) }) if (!availability.canRunNow) { toast.error(availability.message) return } - await runAutomationNowForTarget(automation, automationHostTarget) + const result = await dispatchAutomationRunNow( + automationDispatchContext, + { rowKey: row.key, automationId: automation.id }, + () => runAutomationNowForTarget(automation, rowHostTarget) + ) + setOwnerActionNotice(result.ok ? null : result.notice) + if (!result.ok) { + return + } useAppStore.getState().recordFeatureInteraction('automation-run') + // A run rewrites this host's run history and its next-run projection, and the + // list renders that host's cache — `refresh()` alone only feeds the pre-catalog + // `automations`, which is no longer what is on screen. + invalidateRowHost(row.key, 'run') await hydratePersistedUIState() await refresh() toast.message( @@ -1614,7 +1634,7 @@ export default function AutomationsPage(): React.JSX.Element { ) } - const rerunAutomationRun = async (automation: Automation, run: AutomationRun): Promise => { + const rerunAutomationRun = async (row: AutomationListRow, run: AutomationRun): Promise => { const runId = run.id if (rerunRunIdsInFlightRef.current.has(runId)) { return @@ -1623,7 +1643,17 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await runAutomationNowForTarget(automation, automationHostTarget) + const result = await dispatchAutomationRunNow( + automationDispatchContext, + { rowKey: row.key, automationId: row.automation.id }, + () => runAutomationNowForTarget(row.automation, automationHostTargetFor(row)) + ) + setOwnerActionNotice(result.ok ? null : result.notice) + if (!result.ok) { + await refresh() + return + } + invalidateRowHost(row.key, 'run') await hydratePersistedUIState() await refresh() toast.message( @@ -1651,20 +1681,16 @@ export default function AutomationsPage(): React.JSX.Element { } const runExternalAction = async ( - manager: ExternalAutomationManager, + scope: ExternalAutomationScope, job: ExternalAutomationJob, action: ExternalAutomationAction ): Promise => { - const key = `${manager.id}:${job.id}:${action}` - setExternalActionKey(key) + // Keyed by the scope the row was discovered under — the same one the call + // below routes to — so two hosts running the same provider can neither + // disable each other's buttons nor receive each other's delete. + setExternalActionKey(externalAutomationActionKey(scope, job.id, action)) try { - await window.api.automations.runExternalAction({ - managerId: manager.id, - provider: manager.provider, - target: manager.target, - jobId: job.id, - action - }) + await scopedExternal.runExternalAction(scope, job.id, action) if (action === 'run') { useAppStore.getState().recordFeatureInteraction('automation-run') } @@ -1672,7 +1698,7 @@ export default function AutomationsPage(): React.JSX.Element { // Why: full-page detail keeps selection when the deleted external was open; // without this, detail can fall through to an unrelated local automation. if (action === 'delete') { - const deletedKey = getExternalAutomationKey(manager, job) + const deletedKey = externalAutomationJobKey(scope, job.id) if (selectedExternalKeyRef.current === deletedKey) { selectExternalKey(null) setIsDetailOpen(false) @@ -1716,38 +1742,22 @@ export default function AutomationsPage(): React.JSX.Element { } const fetchExternalAutomationRuns = useCallback( - async ({ manager, job, page, pageSize }) => { - const fallbackRunsPage = { - runs: job.runs.slice(page * pageSize, page * pageSize + pageSize), - totalCount: job.runCount - } - const listExternalRuns = ( - window.api.automations as Partial> - ).listExternalRuns - if (typeof listExternalRuns !== 'function') { - return fallbackRunsPage - } + async ({ scope, job, page, pageSize }) => { try { - const result = await listExternalRuns({ - managerId: manager.id, - provider: manager.provider, - target: manager.target, - jobId: job.id, - page: page + 1, - pageSize - }) - return { - runs: result.runs, - totalCount: result.total - } + const result = await fetchScopedExternalRuns(scope, job, page, pageSize) + return { runs: [...result.runs], totalCount: result.totalCount } } catch (error) { if (isMissingExternalRunsApiError(error)) { - return fallbackRunsPage + // An old host with no runs endpoint still has the runs the job carried. + return { + runs: job.runs.slice(page * pageSize, page * pageSize + pageSize), + totalCount: job.runCount + } } throw error } }, - [] + [fetchScopedExternalRuns] ) const openExternalRunPage = ( @@ -1765,13 +1775,14 @@ export default function AutomationsPage(): React.JSX.Element { const requestExternalAction = ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ): void => { if (action === 'delete') { - setExternalDeleteTarget({ manager, job }) + setExternalDeleteTarget({ manager, job, scope }) return } - void runExternalAction(manager, job, action) + void runExternalAction(scope, job, action) } const confirmDeleteExternalAutomation = async (): Promise => { @@ -1780,7 +1791,9 @@ export default function AutomationsPage(): React.JSX.Element { } const target = externalDeleteTarget setExternalDeleteTarget(null) - await runExternalAction(target.manager, target.job, 'delete') + // The scope the confirmed row was listed under, not one re-derived after the + // dialog opened: a delete is the action least able to survive a wrong host. + await runExternalAction(target.scope, target.job, 'delete') } const openRunWorkspace = (run: AutomationRun): void => { @@ -1908,7 +1921,6 @@ export default function AutomationsPage(): React.JSX.Element {
+ { + setOwnerActionNotice(null) + if (action === 'retry') { + void refresh() + } + }} + onDismiss={() => setOwnerActionNotice(null)} + /> + { + setEditorNotice(null) + void refresh() + }} + onNoticeDismiss={() => setEditorNotice(null)} onProjectChange={handleProjectChange} getRepoHostLabel={getAutomationRepoHostLabel} onCreateTargetChange={handleCreateTargetChange} - onOpenChange={setCreateOpen} + onOpenChange={(open) => { + setCreateOpen(open) + if (!open) { + setEditorNotice(null) + } + }} onDraftChange={setDraft} onSetupDecisionTouched={markSetupDecisionTouched} onApplyTemplate={applyTemplateToDraft} @@ -1946,7 +1982,7 @@ export default function AutomationsPage(): React.JSX.Element { /> { @@ -1986,6 +2022,14 @@ export default function AutomationsPage(): React.JSX.Element { selectedExternalRunPage={selectedExternalRunPage} selectedAutomationRunPage={selectedAutomationRunPage} selectedRuns={selectedRuns} + selectedRunsNotice={selectedRunsNotice} + recoverSelectedRuns={(action) => { + // Reconnect/Update server act on the selected host; the re-ask is what + // brings this automation's history back either way. + hostCatalog.recover(action) + setSelectedAutomationRuns((current) => ({ ...current, notice: null })) + setRunHistoryReloadToken((token) => token + 1) + }} activePaneTab={activePaneTab} relativeNow={relativeNow} externalActionKey={externalActionKey} @@ -2020,11 +2064,13 @@ export default function AutomationsPage(): React.JSX.Element { requestExternalAction={requestExternalAction} openExternalRunPage={openExternalRunPage} openEditExternalDialog={openEditExternalDialog} - runNow={(automation) => void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} - requestDeleteAutomation={requestDeleteAutomation} - rerunAutomationRun={(automation, run) => void rerunAutomationRun(automation, run)} + runNow={() => onSelectedRow(runNow)} + openEditDialog={() => onSelectedRow(openEditDialog)} + toggleAutomation={() => onSelectedRow(toggleAutomation)} + requestDeleteAutomation={() => onSelectedRow(requestDeleteAutomation)} + rerunAutomationRun={(_automation, run) => + onSelectedRow((row) => rerunAutomationRun(row, run)) + } openRunWorkspace={openRunWorkspace} openAutomationRunPage={openAutomationRunPage} onBackToList={() => { @@ -2038,41 +2084,52 @@ export default function AutomationsPage(): React.JSX.Element { setListSort((current) => nextAutomationListSort(current, field))} - selectedId={selectedId} + searchCounts={searchCounts} + hostCatalog={hostCatalog} + externalManagersListed={externalManagersListed} + externalScopeNotice={externalAutomationScopeNotice(externalScopeGate)} + externalManagersUncheckedNotice={externalManagersUncheckedNotice} + onSelectHost={hostCatalog.selectHost} + onRecoverHost={(action, entry) => { + hostCatalog.recover(action, entry) + if (action === 'retry') { + void refresh() + } + }} + isActionEnabled={isAutomationRowActionEnabled} + filteredRows={filteredRows} + filteredExternalAutomationEntries={filteredExternalAutomationEntries} + selectedRowKey={selectedRowKey} selectedExternalKey={selectedExternalKey} - runs={runs} + selectedExternal={selectedExternal} relativeNow={relativeNow} repoMap={repoMap} worktreeMap={worktreeMap} projectHostSetups={projectHostSetups} sshConnectionStates={sshConnectionStates} runtimeStatusByEnvironmentId={runtimeStatusByEnvironmentId} - automationHostTarget={automationHostTarget} - automationSourceHostAvailabilityById={automationSourceHostAvailabilityById} + hostTargetFor={automationHostTargetFor} + automationSourceHostAvailabilityByRowKey={automationSourceHostAvailabilityByRowKey} hostLabelById={hostLabelById} externalActionKey={externalActionKey} - selectAutomationId={selectAutomationId} + selectAutomationRow={selectAutomationRow} selectExternalKey={selectExternalKey} setActivePaneTab={setActivePaneTab} - runNow={(automation) => void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} + runNow={(row) => void runNow(row)} + openEditDialog={(row) => void openEditDialog(row)} + toggleAutomation={(row) => void toggleAutomation(row)} requestDeleteAutomation={requestDeleteAutomation} requestExternalAction={requestExternalAction} openEditExternalDialog={openEditExternalDialog} openCreateDialog={openCreateDialog} onOpenDetail={() => setIsDetailOpen(true)} - onRefresh={() => void refresh()} + onRefresh={() => { + hostCatalog.refreshHosts() + void refresh() + }} isRefreshing={isLoading} /> )} diff --git a/src/renderer/src/components/automations/CreateFromPicker.tsx b/src/renderer/src/components/automations/CreateFromPicker.tsx index a392627ee18..d990be6b12b 100644 --- a/src/renderer/src/components/automations/CreateFromPicker.tsx +++ b/src/renderer/src/components/automations/CreateFromPicker.tsx @@ -43,7 +43,9 @@ export function CreateFromPicker({ triggerClassName?: string onValueChange: (baseBranch: string) => void }): React.JSX.Element { - const activeRuntimeEnvironmentId = useAppStore((state) => + // Per-repo evidence, not the ambient active-runtime setting; the base-ref helpers + // just take a settings-shaped object, so it is synthesized at each call below. + const repoRuntimeEnvironmentId = useAppStore((state) => getRuntimeEnvironmentIdForRepo(state, repoId) ) const repo = repoMap.get(repoId) @@ -116,7 +118,10 @@ export function CreateFromPicker({ } let stale = false setDefaultBaseRef(null) - void getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId) + void getRuntimeRepoBaseRefDefault( + { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, + repoId + ) .then((result) => { if (!stale) { setDefaultBaseRef(result.defaultBaseRef) @@ -130,7 +135,7 @@ export function CreateFromPicker({ return () => { stale = true } - }, [activeRuntimeEnvironmentId, repoId]) + }, [repoRuntimeEnvironmentId, repoId]) React.useEffect(() => { if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) { @@ -148,7 +153,12 @@ export function CreateFromPicker({ let stale = false setIsSearching(true) const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 30) + void searchRuntimeRepoBaseRefs( + { activeRuntimeEnvironmentId: repoRuntimeEnvironmentId }, + repoId, + trimmedQuery, + 30 + ) .then((results) => { if (!stale) { setSearchResults(results) @@ -170,7 +180,7 @@ export function CreateFromPicker({ stale = true window.clearTimeout(timer) } - }, [activeRuntimeEnvironmentId, open, query, repoId]) + }, [repoRuntimeEnvironmentId, open, query, repoId]) return (
diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx index 8d21b62da6f..06c7c66f099 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx @@ -10,6 +10,11 @@ import type { ExternalAutomationProvider } from '../../../../shared/automations-types' import { ExternalAutomationManagers } from './ExternalAutomationManagers' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { externalAutomationActionKey } from './external-automation-scope-keys' // Why: act(...) warnings are silenced by opting this module into the React act // environment, matching how the renderer mounts under test. @@ -73,17 +78,45 @@ function makeManager( } } +/** The desktop-self host every manager here is discovered on unless named otherwise. */ +function makeScope(environmentId: string | null = null): ExternalAutomationScope { + return { + owner: { + authority: + environmentId === null + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId, pairingRevision: 1 }, + selector: { kind: 'self' } + }, + provider: 'hermes' + } +} + +function scoped( + overrides: Partial = {}, + scope: ExternalAutomationScope = makeScope() +): ScopedExternalAutomationManager { + return { scope, manager: makeManager(overrides) } +} + type OnActionMock = ReturnType< typeof vi.fn< ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ) => void > > type OnEditMock = ReturnType< - typeof vi.fn<(manager: ExternalAutomationManager, job: ExternalAutomationJob) => void> + typeof vi.fn< + ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void + > > type RenderOptions = { @@ -93,7 +126,7 @@ type RenderOptions = { } function renderManagers( - managers: ExternalAutomationManager[], + managers: ScopedExternalAutomationManager[], options: RenderOptions = {} ): { onAction: OnActionMock; onEdit: OnEditMock } { const onAction = options.onAction ?? vi.fn() @@ -112,10 +145,10 @@ function renderManagers( return { onAction, onEdit } } -function getSwitch(): HTMLButtonElement { - const node = container.querySelector('button[role="switch"]') +function getSwitch(index = 0): HTMLButtonElement { + const node = container.querySelectorAll('button[role="switch"]')[index] if (!(node instanceof HTMLButtonElement)) { - throw new Error('expected a role="switch" control') + throw new Error(`expected a role="switch" control at ${index}`) } return node } @@ -142,48 +175,109 @@ describe('ExternalAutomationManagers toggle', () => { }) it('renders a switch reflecting the enabled state via aria-checked', () => { - renderManagers([makeManager({ jobs: [makeJob({ enabled: true })] })]) + renderManagers([scoped({ jobs: [makeJob({ enabled: true })] })]) expect(getSwitch().getAttribute('aria-checked')).toBe('true') }) it('renders aria-checked=false for a paused job', () => { - renderManagers([makeManager({ jobs: [makeJob({ enabled: false })] })]) + renderManagers([scoped({ jobs: [makeJob({ enabled: false })] })]) expect(getSwitch().getAttribute('aria-checked')).toBe('false') }) it('dispatches pause when toggling an enabled job', () => { - const manager = makeManager({ jobs: [makeJob({ enabled: true })] }) - const { onAction } = renderManagers([manager]) + const entry = scoped({ jobs: [makeJob({ enabled: true })] }) + const { onAction } = renderManagers([entry]) act(() => { getSwitch().click() }) - expect(onAction).toHaveBeenCalledWith(manager, manager.jobs[0], 'pause') + expect(onAction).toHaveBeenCalledWith( + entry.manager, + entry.manager.jobs[0], + 'pause', + entry.scope + ) }) it('dispatches resume when toggling a paused job', () => { - const manager = makeManager({ jobs: [makeJob({ enabled: false })] }) - const { onAction } = renderManagers([manager]) + const entry = scoped({ jobs: [makeJob({ enabled: false })] }) + const { onAction } = renderManagers([entry]) act(() => { getSwitch().click() }) - expect(onAction).toHaveBeenCalledWith(manager, manager.jobs[0], 'resume') + expect(onAction).toHaveBeenCalledWith( + entry.manager, + entry.manager.jobs[0], + 'resume', + entry.scope + ) }) it('disables the switch when the manager cannot be managed', () => { - renderManagers([makeManager({ canManage: false })]) + renderManagers([scoped({ canManage: false })]) expect(getSwitch().disabled).toBe(true) }) it('shows the sibling spinner only while a pause/resume targets this row', () => { - const manager = makeManager({ jobs: [makeJob({ id: 'job-1', enabled: true })] }) + const entry = scoped({ jobs: [makeJob({ id: 'job-1', enabled: true })] }) // Keyed for the resume action even though the job is enabled — the spinner // must match either pause or resume so it does not vanish when enabled flips. - renderManagers([manager], { runningActionKey: 'manager-1:job-1:resume' }) + renderManagers([entry], { + runningActionKey: externalAutomationActionKey(entry.scope, 'job-1', 'resume') + }) expect(container.querySelector('.animate-spin')).not.toBeNull() }) + it('spins only the host whose action is in flight when two authorities share a manager ID', () => { + // Both hosts report `hermes:local` with the same job ID, because that ID + // names a provider and a target kind but no authority. The page keys the + // in-flight action by scope, so the component must too — matching on + // manager and job alone would spin the wrong host's row, and a user would + // watch a machine they never touched appear to run their automation. + const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1', enabled: true })] } + const desktop = scoped(local, makeScope()) + const runtime = scoped(local, makeScope('env-7')) + + renderManagers([desktop, runtime], { + runningActionKey: externalAutomationActionKey(runtime.scope, 'job-1', 'pause') + }) + + // The spinner sits beside the switch it belongs to, so the switch it shares a + // parent with is the row the component considers in flight. + const spinners = container.querySelectorAll('.animate-spin') + expect(spinners).toHaveLength(1) + expect(spinners[0]?.parentElement?.contains(getSwitch(1))).toBe(true) + expect(spinners[0]?.parentElement?.contains(getSwitch(0))).toBe(false) + }) + + it('labels the two rows apart when two authorities share a manager and job ID', () => { + const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1' })] } + renderManagers([scoped(local, makeScope()), scoped(local, makeScope('env-7'))]) + + // A name element ID built from manager and job alone would repeat, and the + // second switch's aria-labelledby would resolve to the first host's name. + expect(getSwitch(0).getAttribute('aria-labelledby')).not.toBe( + getSwitch(1).getAttribute('aria-labelledby') + ) + }) + + it('dispatches each row against its own authority when two share a manager ID', () => { + const local = { id: 'hermes:local', jobs: [makeJob({ id: 'job-1', enabled: true })] } + const desktop = scoped(local, makeScope()) + const runtime = scoped(local, makeScope('env-7')) + const { onAction } = renderManagers([desktop, runtime]) + + act(() => { + getSwitch(1).click() + }) + + // The scope reaches the handler from the row, so a pause on the runtime's + // row can never be replayed against the desktop's identically named cron job. + expect(onAction).toHaveBeenCalledTimes(1) + expect(onAction.mock.calls[0]?.[3]).toEqual(runtime.scope) + }) + it('keeps the Run/Edit/Delete actions and removes the pause/resume button on hermes', () => { - renderManagers([makeManager({ provider: 'hermes' })]) + renderManagers([scoped({ provider: 'hermes' })]) const labels = actionButtonLabels() expect(labels).toContain('Run external automation') expect(labels).toContain('Edit external automation') @@ -193,7 +287,7 @@ describe('ExternalAutomationManagers toggle', () => { }) it('keeps Run/Delete (no Edit) for openclaw and removes the pause/resume button', () => { - renderManagers([makeManager({ provider: 'openclaw' })]) + renderManagers([scoped({ provider: 'openclaw' })]) const labels = actionButtonLabels() expect(labels).toContain('Run external automation') expect(labels).toContain('Delete external automation') diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index f64684f8c29..815d3918215 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -10,6 +10,15 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { + externalAutomationActionKey, + externalAutomationJobKey, + externalAutomationManagerKey +} from './external-automation-scope-keys' import { formatExternalDate, getExternalProviderLabel, @@ -24,13 +33,15 @@ import { getExternalAutomationActionDisabledMessage } from './external-automatio import { translate } from '@/i18n/i18n' type ExternalAutomationManagersProps = { - managers: ExternalAutomationManager[] + /** Each manager carries the host it was discovered on; its ID cannot name one. */ + managers: readonly ScopedExternalAutomationManager[] now: number runningActionKey: string | null onAction: ( manager: ExternalAutomationManager, job: ExternalAutomationJob, - action: ExternalAutomationAction + action: ExternalAutomationAction, + scope: ExternalAutomationScope ) => void onFetchRuns?: FetchExternalAutomationRuns onOpenRun?: ( @@ -38,15 +49,11 @@ type ExternalAutomationManagersProps = { job: ExternalAutomationJob, run: ExternalAutomationRun ) => void - onEdit?: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void -} - -function actionKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob, - action: ExternalAutomationAction -): string { - return `${manager.id}:${job.id}:${action}` + onEdit?: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + scope: ExternalAutomationScope + ) => void } function ExternalActionButton({ @@ -92,7 +99,7 @@ export function ExternalAutomationManagers({ onOpenRun, onEdit }: ExternalAutomationManagersProps): React.JSX.Element { - const automationCount = managers.reduce((sum, manager) => sum + manager.jobs.length, 0) + const automationCount = managers.reduce((sum, { manager }) => sum + manager.jobs.length, 0) return (
@@ -119,8 +126,8 @@ export function ExternalAutomationManagers({
- {managers.map((manager) => ( -
+ {managers.map(({ scope, manager }) => ( +
{manager.targetLabel}
@@ -154,17 +161,20 @@ export function ExternalAutomationManagers({ manager, actionInProgress: runningActionKey !== null }) + // Scope-qualified so two hosts running the same provider cannot + // label each other's rows or spin each other's buttons. + const jobKey = externalAutomationJobKey(scope, job.id) + const nameId = `automation-name-${jobKey}` + const actionKey = (action: ExternalAutomationAction): string => + externalAutomationActionKey(scope, job.id, action) return (
- + {job.name} @@ -185,17 +195,17 @@ export function ExternalAutomationManagers({ {/* Match either key: job.enabled flips mid-flight, so a single-action match would drop the spinner a render early. */} - {runningActionKey === actionKey(manager, job, 'pause') || - runningActionKey === actionKey(manager, job, 'resume') ? ( + {runningActionKey === actionKey('pause') || + runningActionKey === actionKey('resume') ? ( ) : null} - onAction(manager, job, job.enabled ? 'pause' : 'resume') + onAction(manager, job, job.enabled ? 'pause' : 'resume', scope) } disabled={disabledMessage !== null} - ariaLabelledBy={`automation-name-${manager.id}-${job.id}`} + ariaLabelledBy={nameId} /> @@ -266,9 +276,9 @@ export function ExternalAutomationManagers({ ) } disabled={disabledMessage !== null} - onClick={() => onAction(manager, job, 'run')} + onClick={() => onAction(manager, job, 'run', scope)} > - {runningActionKey === actionKey(manager, job, 'run') ? ( + {runningActionKey === actionKey('run') ? ( ) : ( @@ -284,7 +294,7 @@ export function ExternalAutomationManagers({ ) } disabled={disabledMessage !== null} - onClick={() => onEdit?.(manager, job)} + onClick={() => onEdit?.(manager, job, scope)} > @@ -299,9 +309,9 @@ export function ExternalAutomationManagers({ } className="text-destructive hover:text-destructive" disabled={disabledMessage !== null} - onClick={() => onAction(manager, job, 'delete')} + onClick={() => onAction(manager, job, 'delete', scope)} > - {runningActionKey === actionKey(manager, job, 'delete') ? ( + {runningActionKey === actionKey('delete') ? ( ) : ( @@ -311,6 +321,7 @@ export function ExternalAutomationManagers({ {manager.provider === 'hermes' ? (
Promise type ExternalAutomationRunTableProps = { + scope: ExternalAutomationScope manager: ExternalAutomationManager job: ExternalAutomationJob now: number @@ -59,6 +64,7 @@ function normalizeRunPage( } export function ExternalAutomationRunTable({ + scope, manager, job, now, @@ -67,11 +73,16 @@ export function ExternalAutomationRunTable({ }: ExternalAutomationRunTableProps): React.JSX.Element { const [tableState, setTableState] = useState(() => createExternalAutomationRunTableState(job)) const [isLoading, setIsLoading] = useState(false) + const scopeRef = useRef(scope) const managerRef = useRef(manager) const jobRef = useRef(job) + scopeRef.current = scope managerRef.current = manager jobRef.current = job + // Refetch when the host changes, not just the job: the same manager and job ID + // can be a different machine's cron entry. + const scopeKey = externalAutomationScopeKey(scope) const resolvedTableState = resolveExternalAutomationRunTableState(tableState, job) if (resolvedTableState !== tableState) { @@ -92,6 +103,7 @@ export function ExternalAutomationRunTable({ fetchError: null })) void onFetchRuns({ + scope: scopeRef.current, manager: managerRef.current, job: jobRef.current, page, @@ -124,7 +136,7 @@ export function ExternalAutomationRunTable({ return () => { cancelled = true } - }, [job.id, manager.id, onFetchRuns, page]) + }, [job.id, manager.id, onFetchRuns, page, scopeKey]) const fallbackRuns = job.runs const visibleRuns = onFetchRuns diff --git a/src/renderer/src/components/automations/automation-authority-identity.test.ts b/src/renderer/src/components/automations/automation-authority-identity.test.ts new file mode 100644 index 00000000000..33ace9f748f --- /dev/null +++ b/src/renderer/src/components/automations/automation-authority-identity.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { + automationAuthorityPartitionContext, + automationRuntimePairingRevision, + groupReposByAutomationAuthority +} from './automation-authority-identity' + +function repo(overrides: Partial & { id: string }): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 0, + ...overrides + } as Repo +} + +const DESKTOP = { kind: 'desktop' } as const +const RUNTIME = { kind: 'runtime', environmentId: 'env-1' } as const + +describe('automationAuthorityPartitionContext', () => { + it('answers for the desktop from desktop-owned repos only', () => { + const tables = groupReposByAutomationAuthority([ + repo({ id: 'p1' }), + repo({ id: 'p2', connectionId: 'devbox' }), + repo({ id: 'p3', executionHostId: 'runtime:env-1' }) + ]) + const context = automationAuthorityPartitionContext(tables, DESKTOP) + expect(context.repoConnectionId('p1')).toBeNull() + expect(context.repoConnectionId('p2')).toBe('devbox') + expect(context.repoConnectionId('p3')).toBeUndefined() + expect(context.projectsAuthoritative).toBe(true) + }) + + it('answers for a runtime from that runtime’s repos only, and never authoritatively', () => { + const tables = groupReposByAutomationAuthority([ + repo({ id: 'shared', executionHostId: 'runtime:env-1' }), + repo({ id: 'other', executionHostId: 'runtime:env-2' }), + // Same ID under the desktop, hosted over SSH: legal, and not this authority's answer. + repo({ id: 'shared', connectionId: 'devbox' }) + ]) + const context = automationAuthorityPartitionContext(tables, RUNTIME) + expect(context.repoConnectionId('shared')).toBeNull() + expect(context.repoConnectionId('other')).toBeUndefined() + expect(context.projectsAuthoritative).toBe(false) + }) + + it('reports an unmirrored authority as empty rather than borrowing another table', () => { + const tables = groupReposByAutomationAuthority([repo({ id: 'p1' })]) + const context = automationAuthorityPartitionContext(tables, { + kind: 'runtime', + environmentId: 'never-connected' + }) + expect(context.repoConnectionId('p1')).toBeUndefined() + expect(context.projectsAuthoritative).toBe(false) + }) +}) + +describe('automationRuntimePairingRevision', () => { + it('prefers the reported revision and rejects an unknown environment', () => { + const environments = [ + { id: 'env-1', createdAt: 10, pairingRevision: 4 }, + { id: 'env-2', createdAt: 7 } + ] + expect(automationRuntimePairingRevision(environments, 'env-1')).toBe(4) + expect(automationRuntimePairingRevision(environments, 'env-2')).toBe(7) + expect(automationRuntimePairingRevision(environments, 'env-3')).toBe(-1) + }) +}) diff --git a/src/renderer/src/components/automations/automation-authority-identity.ts b/src/renderer/src/components/automations/automation-authority-identity.ts new file mode 100644 index 00000000000..25b22f368d9 --- /dev/null +++ b/src/renderer/src/components/automations/automation-authority-identity.ts @@ -0,0 +1,89 @@ +/** + * The two live values an authority's fence is keyed by, read from mirrored store + * state rather than remembered. + * + * Both answer the same question — which incarnation is this authority right + * now — and both must be read at the moment of use: a cached pairing revision + * survives a re-pair, and a cached repo table survives a repo moving hosts, so + * either one held too long fences a request against an authority that is gone. + */ + +import type { Repo } from '../../../../shared/repo-types' +import type { LegacyAutomationPartitionContext } from '../../../../shared/automation-legacy-list-partition' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' + +type RepoConnection = Pick + +export type AutomationAuthorityRepoTables = ReadonlyMap> + +/** Mirrors the authority's own rule: `undefined` = not in this table, `null` = local, string = SSH. */ +export function repoConnectionIdIn( + repoTable: ReadonlyMap +): (repoId: string) => string | null | undefined { + return (repoId) => { + const repo = repoTable.get(repoId) + return repo ? repo.connectionId?.trim() || null : undefined + } +} + +const EMPTY_REPO_TABLE: ReadonlyMap = new Map() + +function repoOwningAuthority(repo: Repo): StableAutomationAuthorityRef { + const host = parseExecutionHostId(getRepoExecutionHostId(repo)) + // A desktop SSH repo is still desktop-stored; only a runtime host owns its own registry. + return host?.kind === 'runtime' + ? { kind: 'runtime', environmentId: host.environmentId } + : { kind: 'desktop' } +} + +/** Splits the flat repo list into one table per owning authority. */ +export function groupReposByAutomationAuthority( + repos: readonly Repo[] +): AutomationAuthorityRepoTables { + const tables = new Map>() + for (const repo of repos) { + const key = automationAuthorityCatalogKey(repoOwningAuthority(repo)) + let table = tables.get(key) + if (!table) { + table = new Map() + tables.set(key, table) + } + table.set(repo.id, repo) + } + return tables +} + +/** + * The legacy partition context for the authority that answered. + * + * Only the desktop table is a verdict. A runtime's repos reach this client as a + * mirror that is populated on connect and can lag or be empty, so a miss there + * leaves the record unverified instead of declaring its project gone — and a + * desktop repo that happens to share an ID never gets to answer for it. + */ +export function automationAuthorityPartitionContext( + tables: AutomationAuthorityRepoTables, + authority: StableAutomationAuthorityRef +): LegacyAutomationPartitionContext { + return { + repoConnectionId: repoConnectionIdIn( + tables.get(automationAuthorityCatalogKey(authority)) ?? EMPTY_REPO_TABLE + ), + projectsAuthoritative: authority.kind === 'desktop' + } +} + +/** + * The revision the runtime transport guard compares, read from the saved + * environment list so a re-pair is observed rather than cached. An unknown + * environment answers -1, which never matches a real revision. + */ +export function automationRuntimePairingRevision( + environments: readonly { id: string; createdAt: number; pairingRevision?: number }[], + environmentId: string +): number { + const environment = environments.find((candidate) => candidate.id === environmentId) + return environment ? (environment.pairingRevision ?? environment.createdAt) : -1 +} diff --git a/src/renderer/src/components/automations/automation-captured-owner.test.ts b/src/renderer/src/components/automations/automation-captured-owner.test.ts new file mode 100644 index 00000000000..ffc998a5b36 --- /dev/null +++ b/src/renderer/src/components/automations/automation-captured-owner.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostRow } from './automation-host-cache-types' +import { + automationActionAvailability, + captureAutomationOwners, + capturedAutomationOwner, + isAutomationActionEnabled, + UNCAPTURED_AUTOMATION_OWNER +} from './automation-captured-owner' + +const DESKTOP_SELF: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} + +const RUNTIME_SELF: AutomationOwnerRef = { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 1 }, + selector: { kind: 'self' } +} + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a-1', + name: 'Nightly', + prompt: 'sweep', + precheck: null, + agentId: 'claude', + runContext: null, + 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: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function row(overrides: Partial): AutomationHostRow { + return { + automation: automation(), + owner: DESKTOP_SELF, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false, + ...overrides + } +} + +describe('captured owner availability', () => { + it('lets every action through on an owned row and repeats its precondition', () => { + const availability = automationActionAvailability( + { owner: DESKTOP_SELF, selector: { kind: 'self' } }, + 'run' + ) + expect(availability).toEqual({ + kind: 'owned', + owner: DESKTOP_SELF, + precondition: { selector: { kind: 'self' } } + }) + }) + + it('keeps delete and pause fenceable on an orphan while refusing to run it', () => { + const orphan = { owner: null, selector: { kind: 'orphan' as const, issue: 'gone' } } + expect(automationActionAvailability(orphan, 'delete')).toEqual({ + kind: 'orphan-fenced', + precondition: { selector: { kind: 'orphan' } } + }) + expect(automationActionAvailability(orphan, 'toggle').kind).toBe('orphan-fenced') + expect(automationActionAvailability(orphan, 'run').kind).toBe('blocked') + expect(automationActionAvailability(orphan, 'edit').kind).toBe('blocked') + expect(isAutomationActionEnabled(orphan, 'run')).toBe(false) + expect(isAutomationActionEnabled(orphan, 'delete')).toBe(true) + }) + + it('blocks every action on a legacy row and asks for a server update', () => { + // A qualified selector with no owner means the authority never sent a generation. + const legacy = { owner: null, selector: { kind: 'ssh' as const, targetId: 'box' } } + const availability = automationActionAvailability(legacy, 'delete') + expect(availability.kind).toBe('blocked') + expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server') + }) + + it('reports a row with no metadata as uncaptured rather than blocked', () => { + expect(automationActionAvailability(UNCAPTURED_AUTOMATION_OWNER, 'run').kind).toBe('uncaptured') + expect(capturedAutomationOwner(new Map(), 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER) + expect(capturedAutomationOwner(null, 'missing')).toBe(UNCAPTURED_AUTOMATION_OWNER) + }) + + it('keys captured owners by row key', () => { + const captured = captureAutomationOwners([ + { rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) }, + { + rowKey: 'row|desktop|a-2', + row: row({ + automation: automation({ id: 'a-2' }), + owner: null, + selector: { kind: 'orphan', issue: 'gone' } + }) + } + ]) + expect(captured.get('row|desktop|a-1')?.owner).toEqual(DESKTOP_SELF) + expect(captured.get('row|desktop|a-2')?.owner).toBeNull() + }) + + it("keeps both authorities' owners when they hold the same automation ID", () => { + const captured = captureAutomationOwners([ + { rowKey: 'row|desktop|a-1', row: row({ automation: automation({ id: 'a-1' }) }) }, + { + rowKey: 'row|runtime|a-1', + row: row({ automation: automation({ id: 'a-1' }), owner: RUNTIME_SELF }) + } + ]) + expect(captured.get('row|desktop|a-1')?.owner).toEqual(DESKTOP_SELF) + expect(captured.get('row|runtime|a-1')?.owner).toEqual(RUNTIME_SELF) + }) +}) diff --git a/src/renderer/src/components/automations/automation-captured-owner.ts b/src/renderer/src/components/automations/automation-captured-owner.ts new file mode 100644 index 00000000000..761dd787e78 --- /dev/null +++ b/src/renderer/src/components/automations/automation-captured-owner.ts @@ -0,0 +1,156 @@ +/** + * What a row's captured owner permits. + * + * The owner is captured when the row is listed and travels with it; nothing + * here re-derives ownership from the active runtime, the desktop store, or the + * record's `runContext`. That is the whole point: an automation the user is + * looking at may have moved hosts since it was fetched, and the fence only + * works if the request names the incarnation the user actually saw. + * + * Three outcomes, kept distinct because they mean different things to the user: + * an owned row acts normally; an orphan row still deletes and pauses under the + * orphan precondition but can never execute; a legacy row proves nothing and + * stays view-only until its authority advertises fencing. + */ + +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' +import { translate } from '@/i18n/i18n' +import type { AutomationHostRow, AutomationHostRowSelector } from './automation-host-cache-types' +import { ORPHAN_OWNER_PRECONDITION, ownerPrecondition } from './automation-scoped-list-client' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' + +/** Every action the page can start against one stored automation. */ +export type AutomationRowAction = 'edit' | 'save' | 'toggle' | 'delete' | 'run' | 'history' + +export type AutomationActionBlockReason = 'orphan' | 'unfenced' + +export type AutomationActionBlock = { + reason: AutomationActionBlockReason + message: string + /** Null when nothing the UI can offer would fix it; the caller then shows no button. */ + recovery: AutomationHostRecoveryAction | null +} + +export type AutomationCapturedOwner = { + owner: AutomationOwnerRef | null + /** How the authority qualified the row; null when no list metadata was captured at all. */ + selector: AutomationHostRowSelector | null +} + +export type AutomationActionAvailability = + | { kind: 'owned'; owner: AutomationOwnerRef; precondition: AutomationOwnerPrecondition } + | { kind: 'orphan-fenced'; precondition: AutomationOwnerPrecondition } + | { kind: 'blocked'; block: AutomationActionBlock } + /** + * No owner metadata exists for this row yet, so the caller keeps its legacy + * unfenced path. Removed with the inference in Step 9, never in the same + * change — a mixed-version client still depends on it. + */ + | { kind: 'uncaptured' } + +export const UNCAPTURED_AUTOMATION_OWNER: AutomationCapturedOwner = { owner: null, selector: null } + +/** Delete and pause survive on an orphan; running it would need a host that no longer exists. */ +const ORPHAN_ACTIONS: ReadonlySet = new Set(['delete', 'toggle', 'history']) + +function orphanBlock(): AutomationActionBlock { + return { + reason: 'orphan', + message: translate( + 'auto.components.automations.capturedOwner.orphan', + 'This automation has no host to run on. Delete it, or re-add the host it belongs to.' + ), + recovery: null + } +} + +function unfencedBlock(): AutomationActionBlock { + return { + reason: 'unfenced', + message: translate( + 'auto.components.automations.capturedOwner.unfenced', + 'This host cannot confirm which automation it is changing, so this automation is read-only here.' + ), + recovery: 'update-server' + } +} + +export function automationActionAvailability( + captured: AutomationCapturedOwner, + action: AutomationRowAction +): AutomationActionAvailability { + if (captured.owner) { + return { + kind: 'owned', + owner: captured.owner, + precondition: ownerPrecondition(captured.owner) + } + } + if (!captured.selector) { + return { kind: 'uncaptured' } + } + if (captured.selector.kind === 'orphan') { + return ORPHAN_ACTIONS.has(action) + ? { kind: 'orphan-fenced', precondition: ORPHAN_OWNER_PRECONDITION } + : { kind: 'blocked', block: orphanBlock() } + } + // A qualified selector with no owner is a legacy row: no generation was ever captured. + return { kind: 'blocked', block: unfencedBlock() } +} + +/** True only for actions the user can start right now; drives the disabled state. */ +export function isAutomationActionEnabled( + captured: AutomationCapturedOwner, + action: AutomationRowAction +): boolean { + return automationActionAvailability(captured, action).kind !== 'blocked' +} + +/** A committed host row plus the list-row key its actions are looked up by. */ +export type AutomationCapturedRow = { + /** `automationListRowKey`: authority-qualified and incarnation-free. */ + rowKey: string + row: AutomationHostRow +} + +/** + * The owner map the page keys its actions by. Built from the rows a host query + * committed, so a row that never loaded simply has no entry and its actions + * fall back rather than guessing an owner. + * + * Keyed by the row key, not the automation ID: an ID is unique only inside one + * authority, so under All hosts a bare-ID map hands every colliding row the + * owner of whichever host was listed last. The key stays incarnation-free + * because it answers "which row is this" — the incarnation lives in the captured + * `owner` it returns, which is what the request fences on. + */ +export function captureAutomationOwners( + rows: Iterable +): Map { + const captured = new Map() + for (const { rowKey, row } of rows) { + captured.set(rowKey, { owner: row.owner, selector: row.selector }) + } + return captured +} + +/** + * Identifies the authority a row was listed under. Distinguishes "listed under + * this owner" from "listed with no owner at all", so a bare automation ID — + * unique only inside one authority — is never what a cached result is matched by. + */ +export function capturedAutomationOwnerKey(captured: AutomationCapturedOwner): string { + if (captured.owner) { + return ownerKey(captured.owner) + } + return captured.selector ? `selector:${captured.selector.kind}` : 'uncaptured' +} + +export function capturedAutomationOwner( + owners: ReadonlyMap | null | undefined, + rowKey: string | null +): AutomationCapturedOwner { + return (rowKey === null ? undefined : owners?.get(rowKey)) ?? UNCAPTURED_AUTOMATION_OWNER +} diff --git a/src/renderer/src/components/automations/automation-create-destination.test.ts b/src/renderer/src/components/automations/automation-create-destination.test.ts new file mode 100644 index 00000000000..8e2090be6fd --- /dev/null +++ b/src/renderer/src/components/automations/automation-create-destination.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationCatalogHydrationEvidence, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { + automationCreateHostStableKey, + automationCreateProjectMismatch, + preselectAutomationCreateHost, + resolveAutomationCreateDestination, + revalidateAutomationCreateDestination, + soleAutomationCreateHost +} from './automation-create-destination' +import { groupReposByAutomationAuthority } from './automation-authority-identity' +import type { Repo } from '../../../../shared/repo-types' + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'desktop:self', + label: 'This computer', + authorityLabel: 'Desktop', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function sshEntry(targetGeneration: number): AutomationHostCatalogEntry { + return entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'box' } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 'box', targetGeneration } + }, + stableKey: 'desktop:ssh:box', + kind: 'ssh' + }) +} + +describe('create destination resolution', () => { + it('states the destination from the chosen host', () => { + expect(resolveAutomationCreateDestination(entry())).toMatchObject({ + status: 'ready', + authority: { kind: 'desktop' }, + destination: { selector: { kind: 'self' } } + }) + }) + + it('asks for a choice instead of defaulting one', () => { + expect(resolveAutomationCreateDestination(null)).toEqual({ + status: 'choice-required', + reason: 'unselected' + }) + expect(resolveAutomationCreateDestination(entry({ kind: 'orphan' }))).toEqual({ + status: 'choice-required', + reason: 'orphan' + }) + expect(resolveAutomationCreateDestination(entry({ owner: null }))).toEqual({ + status: 'choice-required', + reason: 'unavailable' + }) + }) + + it('prefers the explicit selection and only then the active workspace', () => { + const entries = [entry(), sshEntry(3)] + expect( + preselectAutomationCreateHost(entries, 'desktop:ssh:box', 'desktop:self')?.stableKey + ).toBe('desktop:ssh:box') + expect(preselectAutomationCreateHost(entries, null, 'desktop:self')?.stableKey).toBe( + 'desktop:self' + ) + // An unresolvable workspace host leaves the choice open rather than picking one. + expect(preselectAutomationCreateHost(entries, null, 'desktop:ssh:gone')).toBeNull() + expect(preselectAutomationCreateHost(entries, null, null)).toBeNull() + }) +}) + +describe('create destination revalidation', () => { + const captured = { + authority: { kind: 'desktop' } as const, + destination: { selector: { kind: 'ssh' as const, targetId: 'box', targetGeneration: 3 } }, + entry: sshEntry(3) + } + + it('accepts a destination whose incarnation is unchanged', () => { + expect(revalidateAutomationCreateDestination(captured, [sshEntry(3)]).status).toBe('ready') + }) + + it('reports a re-registered target as stale rather than following it', () => { + expect(revalidateAutomationCreateDestination(captured, [sshEntry(4)])).toMatchObject({ + status: 'stale' + }) + }) + + it('reports a host that left the catalog as needing a choice', () => { + expect(revalidateAutomationCreateDestination(captured, [])).toEqual({ + status: 'choice-required', + reason: 'unselected' + }) + }) +}) + +describe('sole create host', () => { + const hydrated: AutomationCatalogHydrationEvidence = { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + + it('states the only eligible host and never one of several', () => { + expect(soleAutomationCreateHost([entry()], hydrated)?.stableKey).toBe('desktop:self') + expect(soleAutomationCreateHost([entry(), sshEntry(3)], hydrated)).toBeNull() + // An orphan bucket and an unowned host are not candidates at all. + expect( + soleAutomationCreateHost([entry(), entry({ stableKey: 'orphan', kind: 'orphan' })], hydrated) + ?.stableKey + ).toBe('desktop:self') + }) + + it('states nothing until the catalog has settled', () => { + expect( + soleAutomationCreateHost([entry()], { ...hydrated, runtimeCatalogSettled: false }) + ).toBeNull() + expect( + soleAutomationCreateHost([entry()], { ...hydrated, desktopSshHydrated: false }) + ).toBeNull() + }) +}) + +describe('create host stable key', () => { + it('maps a workspace host to the catalog host that would store its automations', () => { + expect(automationCreateHostStableKey('local')).toBe('host:desktop:self') + // A desktop SSH workspace is still desktop-stored; only the selector differs. + expect(automationCreateHostStableKey('ssh:box')).toBe('host:desktop:ssh:box') + expect(automationCreateHostStableKey('runtime:gpu')).toBe('host:runtime:gpu:self') + expect(automationCreateHostStableKey(null)).toBeNull() + }) +}) + +describe('create project mismatch', () => { + function repo(overrides: Partial): Repo { + return { + id: 'repo-1', + displayName: 'orca', + path: '/repos/orca', + badgeColor: '#000000', + addedAt: 1, + worktreeBaseRef: 'main', + ...overrides + } as Repo + } + const desktopSelf = { + authority: { kind: 'desktop' } as const, + destination: { selector: { kind: 'self' as const } }, + entry: entry() + } + + it('refuses a project the destination authority does not hold as local', () => { + const tables = groupReposByAutomationAuthority([ + repo({ id: 'runtime-repo', executionHostId: 'runtime:gpu' }), + repo({ id: 'ssh-repo', connectionId: 'box' }) + ]) + // No connection ID is not evidence of local: this repo is the runtime's. + expect(automationCreateProjectMismatch(tables, desktopSelf, 'runtime-repo')).toBe(true) + expect(automationCreateProjectMismatch(tables, desktopSelf, 'ssh-repo')).toBe(true) + expect(automationCreateProjectMismatch(tables, desktopSelf, 'repo-1')).toBe(true) + expect( + automationCreateProjectMismatch( + tables, + { + ...desktopSelf, + destination: { selector: { kind: 'ssh', targetId: 'box', targetGeneration: 3 } } + }, + 'ssh-repo' + ) + ).toBe(false) + }) + + it('leaves a runtime mirror miss unverified rather than calling it a mismatch', () => { + const tables = groupReposByAutomationAuthority([repo({ id: 'repo-1' })]) + const runtimeSelf = { + authority: { kind: 'runtime' as const, environmentId: 'gpu', pairingRevision: 4 }, + destination: { selector: { kind: 'self' as const } }, + entry: entry() + } + expect(automationCreateProjectMismatch(tables, runtimeSelf, 'repo-1')).toBe(false) + // The desktop's own table is a verdict, so its miss does refuse. + expect(automationCreateProjectMismatch(tables, desktopSelf, 'missing')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts new file mode 100644 index 00000000000..dcafac0c8a4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-create-destination.ts @@ -0,0 +1,167 @@ +/** + * Where a new automation is created, stated explicitly. + * + * Creation never infers a host. A concrete host filter constrains the + * destination; under All hosts the active workspace's host may pre-fill it, but + * only when that host resolves to a catalog entry with a real executable owner. + * Anything less — an orphan bucket, an unhydrated or ghost host, no selection + * at all — asks the user instead of picking for them, because a silent default + * here creates a scheduled job on a machine nobody chose. + * + * The destination is re-resolved immediately before submit so a host that + * changed incarnation while the form was open fails closed with the form + * intact, rather than landing the record on a re-registered target. + */ + +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' +import { hostStableKey, isSameAutomationOwner } from '../../../../shared/automation-owner-key' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { + AutomationCatalogHydrationEvidence, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { + repoConnectionIdIn, + type AutomationAuthorityRepoTables +} from './automation-authority-identity' + +export type AutomationCreateDestinationChoiceReason = + /** No host is selected and nothing may be assumed. */ + | 'unselected' + /** The selection is the authority's orphan bucket, which cannot own new records. */ + | 'orphan' + /** The host is known but has no executable owner yet: ghost, unhydrated, or legacy. */ + | 'unavailable' + +export type AutomationCreateDestination = { + authority: AutomationAuthorityRef + destination: AutomationDestination + /** The entry the user will see named on the form before submit. */ + entry: AutomationHostCatalogEntry +} + +export type AutomationCreateDestinationResolution = + | ({ status: 'ready' } & AutomationCreateDestination) + | { status: 'choice-required'; reason: AutomationCreateDestinationChoiceReason } + +export function resolveAutomationCreateDestination( + entry: AutomationHostCatalogEntry | null | undefined +): AutomationCreateDestinationResolution { + if (!entry) { + return { status: 'choice-required', reason: 'unselected' } + } + if (entry.kind === 'orphan') { + return { status: 'choice-required', reason: 'orphan' } + } + if (!entry.owner) { + return { status: 'choice-required', reason: 'unavailable' } + } + return { + status: 'ready', + authority: entry.owner.authority, + destination: { selector: entry.owner.selector }, + entry + } +} + +/** + * Under All hosts, a pre-fill is a convenience, never a fallback: an + * unresolvable active workspace leaves the choice to the user. + */ +export function preselectAutomationCreateHost( + entries: readonly AutomationHostCatalogEntry[], + selectedStableKey: string | null, + activeWorkspaceStableKey: string | null +): AutomationHostCatalogEntry | null { + const key = selectedStableKey ?? activeWorkspaceStableKey + if (!key) { + return null + } + return entries.find((entry) => entry.stableKey === key) ?? null +} + +/** + * Re-resolves the captured destination against the live catalog. A changed + * incarnation is reported rather than followed, so the caller can keep the form + * and say which host moved. + */ +export function revalidateAutomationCreateDestination( + captured: AutomationCreateDestination, + entries: readonly AutomationHostCatalogEntry[] +): AutomationCreateDestinationResolution | { status: 'stale'; entry: AutomationHostCatalogEntry } { + const current = entries.find((entry) => entry.stableKey === captured.entry.stableKey) + const resolved = resolveAutomationCreateDestination(current) + if (resolved.status !== 'ready') { + return resolved + } + return isSameAutomationOwner(destinationOwner(resolved), destinationOwner(captured)) + ? resolved + : { status: 'stale', entry: resolved.entry } +} + +/** + * One eligible host is not a guess: with nothing else the user could choose, the + * destination is still stated. Gated on positive hydration evidence, because a + * catalog that has not settled can look single-host while a second one loads. + */ +export function soleAutomationCreateHost( + entries: readonly AutomationHostCatalogEntry[], + hydration: AutomationCatalogHydrationEvidence +): AutomationHostCatalogEntry | null { + if (!hydration.runtimeCatalogSettled || !hydration.desktopSshHydrated) { + return null + } + const eligible = entries.filter((entry) => entry.kind !== 'orphan' && entry.owner) + return eligible.length === 1 ? (eligible[0] ?? null) : null +} + +/** The catalog host a workspace's execution host names, for the All-hosts pre-fill. */ +export function automationCreateHostStableKey(hostId: string | null | undefined): string | null { + const host = parseExecutionHostId(hostId) + if (!host) { + return null + } + if (host.kind === 'runtime') { + return hostStableKey({ + authority: { kind: 'runtime', environmentId: host.environmentId }, + selector: { kind: 'self' } + }) + } + // A desktop SSH workspace is still desktop-stored; only the selector differs. + return hostStableKey({ + authority: { kind: 'desktop' }, + selector: host.kind === 'ssh' ? { kind: 'ssh', targetId: host.targetId } : { kind: 'self' } + }) +} + +/** + * Whether the project can live on the destination at all, checked against the + * destination authority's own repo table. + * + * Only the desktop table is a verdict: a runtime's repos reach this client as a + * mirror that can lag, so a miss there leaves the project unverified for the + * authority to reject, while a miss in the desktop's own table is proof. + */ +export function automationCreateProjectMismatch( + tables: AutomationAuthorityRepoTables, + destination: AutomationCreateDestination, + projectId: string +): boolean { + const authority = destination.authority + const table = tables.get(automationAuthorityCatalogKey(authority)) + const connectionId = table ? repoConnectionIdIn(table)(projectId) : undefined + if (connectionId === undefined) { + return authority.kind === 'desktop' + } + const selector = destination.destination.selector + return selector.kind === 'ssh' ? connectionId !== selector.targetId : connectionId !== null +} + +function destinationOwner(value: AutomationCreateDestination): AutomationOwnerRef { + return { authority: value.authority, selector: value.destination.selector } +} diff --git a/src/renderer/src/components/automations/automation-delete-confirm-preference.ts b/src/renderer/src/components/automations/automation-delete-confirm-preference.ts new file mode 100644 index 00000000000..f1a41d5f63b --- /dev/null +++ b/src/renderer/src/components/automations/automation-delete-confirm-preference.ts @@ -0,0 +1,44 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +/** + * Turning off the delete confirmation is a one-click, easily regretted change, + * so the acknowledgement carries the way back to it rather than just reporting + * success. + */ + +export type AutomationDeleteConfirmPreferenceActions = { + updateSettings: (update: { skipDeleteAutomationConfirm: boolean }) => unknown + openSettingsPage: () => void + openSettingsTarget: (target: { pane: 'general'; repoId: null; sectionId: string }) => void +} + +export function persistSkipDeleteAutomationConfirm( + actions: AutomationDeleteConfirmPreferenceActions +): void { + void actions.updateSettings({ skipDeleteAutomationConfirm: true }) + toast.success( + translate( + 'auto.components.automations.AutomationsPage.690b94da54', + "We'll skip this confirmation next time." + ), + { + description: translate( + 'auto.components.automations.AutomationsPage.d2a01b0b6f', + 'You can change this in Settings.' + ), + duration: 8000, + action: { + label: translate('auto.components.automations.AutomationsPage.8a3226f172', 'Open Settings'), + onClick: () => { + actions.openSettingsPage() + actions.openSettingsTarget({ + pane: 'general', + repoId: null, + sectionId: 'general-skip-delete-automation-confirm' + }) + } + } + } + ) +} diff --git a/src/renderer/src/components/automations/automation-edit-draft.ts b/src/renderer/src/components/automations/automation-edit-draft.ts new file mode 100644 index 00000000000..f4f15bff013 --- /dev/null +++ b/src/renderer/src/components/automations/automation-edit-draft.ts @@ -0,0 +1,77 @@ +/** + * Turns a saved record back into the editor's draft shape. + * + * Both builders are total: a schedule the editor cannot represent produces a + * warning rather than a silently rewritten one, because saving would otherwise + * replace a working cron the user never chose to change. + */ + +import type { Automation, ExternalAutomationJob } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { + isValidAutomationCronSchedule, + isValidAutomationSchedule, + tryParseAutomationRrule +} from '../../../../shared/automation-schedules' +import type { AutomationDraft } from './AutomationEditorDialog' +import { AUTOMATION_DEFAULT_TIME, formatTimeInput } from './automation-draft-model' +import { getAutomationSetupDecisionDraftValue } from './automation-setup-decision' + +export function buildAutomationEditDraft(automation: Automation): AutomationDraft { + const schedule = tryParseAutomationRrule(automation.rrule) + const hasCustomSchedule = !schedule && isValidAutomationSchedule(automation.rrule) + return { + name: automation.name, + prompt: automation.prompt, + agentId: automation.agentId, + projectId: getAutomationRunRepoId(automation), + workspaceMode: automation.workspaceMode, + workspaceId: automation.workspaceId ?? '', + baseBranch: automation.baseBranch ?? '', + setupDecision: getAutomationSetupDecisionDraftValue({ + workspaceMode: automation.workspaceMode, + persistedSetupDecision: automation.setupDecision + }), + reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession, + precheckCommand: automation.precheck?.command ?? '', + precheckTimeoutSeconds: String(automation.precheck?.timeoutSeconds ?? 60), + preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), + time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, + dayOfWeek: String(schedule?.dayOfWeek ?? 1), + customSchedule: hasCustomSchedule ? automation.rrule : '', + missedRunGraceMinutes: String(automation.missedRunGraceMinutes), + scheduleWarning: + schedule || hasCustomSchedule + ? null + : 'This automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' + } +} + +export function buildExternalAutomationEditDraft( + job: ExternalAutomationJob, + placement: { projectId: string; workspaceId: string } +): AutomationDraft { + const rawSchedule = job.rawSchedule?.trim() ?? '' + const hasCustomSchedule = isValidAutomationCronSchedule(rawSchedule) + return { + name: job.name, + prompt: job.prompt ?? job.promptPreview, + agentId: 'hermes', + projectId: placement.projectId, + workspaceMode: 'existing', + workspaceId: placement.workspaceId, + baseBranch: '', + setupDecision: undefined, + reuseSession: false, + precheckCommand: '', + precheckTimeoutSeconds: '60', + preset: hasCustomSchedule ? 'custom' : 'weekdays', + time: AUTOMATION_DEFAULT_TIME, + dayOfWeek: '1', + customSchedule: hasCustomSchedule ? rawSchedule : '', + missedRunGraceMinutes: '720', + scheduleWarning: hasCustomSchedule + ? null + : 'This Hermes automation has an unsupported saved schedule. Pick a supported schedule before saving changes.' + } +} diff --git a/src/renderer/src/components/automations/automation-enablement-notice.ts b/src/renderer/src/components/automations/automation-enablement-notice.ts new file mode 100644 index 00000000000..6fb82dfaa43 --- /dev/null +++ b/src/renderer/src/components/automations/automation-enablement-notice.ts @@ -0,0 +1,47 @@ +/** + * Copy for why an automation is off — and, when it was not the user, what to do. + * + * Only the migration's own stamp produces new wording. A user's pause and a + * record from before the stamp existed both read exactly as they did before, so + * nothing changes for the automations whose state was never in question. + */ + +import type { Automation } from '../../../../shared/automations-types' +import { isAutomationDisabledByOwnerMigration } from '../../../../shared/automation-enablement-decision' +import { translate } from '@/i18n/i18n' + +type AutomationEnablement = Pick + +/** + * Names the decider, not the reason: this label rides in the row's next-run + * column and the detail badge, both too narrow for a cause. The cause is the + * notice. Callers show a next-run time instead when the record is running. + */ +export function automationPausedLabel(automation: AutomationEnablement): string { + return isAutomationDisabledByOwnerMigration(automation) + ? translate('auto.components.automations.enablement.pausedByOrca', 'Paused by Orca') + : translate('auto.components.automations.enablement.paused', 'Paused') +} + +export type AutomationEnablementNotice = { reason: string; recovery: string } + +/** Null for every state the user already understands, which is all but one. */ +export function automationEnablementNotice( + automation: AutomationEnablement +): AutomationEnablementNotice | null { + if (!isAutomationDisabledByOwnerMigration(automation)) { + return null + } + return { + // Why not "its host was removed": the migration also pauses records whose + // workspace spans hosts, where nothing was removed and no single host owns it. + reason: translate( + 'auto.components.automations.enablement.hostChangedReason', + 'Orca paused this automation because it could not tell which host it belongs to. You did not pause it.' + ), + recovery: translate( + 'auto.components.automations.enablement.hostChangedRecovery', + 'Re-add that host to restore it, resume the automation to run it anyway, or delete it if you no longer need it.' + ) + } +} diff --git a/src/renderer/src/components/automations/automation-host-cache-controller.test.ts b/src/renderer/src/components/automations/automation-host-cache-controller.test.ts new file mode 100644 index 00000000000..8fd748f8dc8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-controller.test.ts @@ -0,0 +1,317 @@ +/** + * The controller is where a rebuilt catalog meets the cache: it must advance + * generations before fetching, forget hosts the catalog dropped, and leave an + * unreachable authority alone instead of dialling it. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache } from './automation-host-cache' +import { createAutomationHostQueryController } from './automation-host-cache-controller' +import { + getAuthorityCatalogGeneration, + resetAutomationHostCatalogGenerationsForTests +} from './automation-host-catalog-generation' +import type { + AutomationAuthorityHealth, + AutomationHostCatalog, + AutomationHostCatalogEntry, + AutomationHostCatalogState, + AutomationHostQuerySupport +} from './automation-host-catalog-types' +import type { AutomationHostSchedulerTransport } from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } +const RUNTIME_SSH: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' } +} +const DESKTOP_SSH: StableAutomationCatalogRef = { + authority: DESKTOP, + selector: { kind: 'ssh', targetId: 'target-1' } +} + +function entry( + ref: StableAutomationCatalogRef, + overrides: { + authorityHealth?: AutomationAuthorityHealth + catalogState?: AutomationHostCatalogState + querySupport?: AutomationHostQuerySupport + targetGeneration?: number + } = {} +): AutomationHostCatalogEntry { + return { + stableRef: ref, + owner: { + authority: + ref.authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: ref.authority.environmentId, pairingRevision: 2 }, + selector: + ref.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: overrides.targetGeneration ?? 5 + } + : { kind: 'self' } + }, + stableKey: hostStableKey(ref), + label: 'Host', + authorityLabel: 'Authority', + kind: ref.selector.kind === 'ssh' ? 'ssh' : 'self', + catalogState: overrides.catalogState ?? 'authoritative', + authorityHealth: overrides.authorityHealth ?? 'fresh', + executionHealth: 'connected', + querySupport: overrides.querySupport ?? 'scoped' + } +} + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((item) => [item.stableKey, item])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(['env-1']), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +function emptyList(): ScopedAutomationList { + return { automations: [], items: [], orphanCount: 0, invalidRows: 0 } +} + +function listOf(ids: readonly string[]): ScopedAutomationList { + return { + automations: ids.map((id) => ({ id, name: id }) as Automation), + items: ids.map((id) => ({ automationId: id, selector: { kind: 'self' as const } })), + orphanCount: 0, + invalidRows: 0 + } +} + +function createController(listScoped: AutomationHostSchedulerTransport['listScoped']) { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const controller = createAutomationHostQueryController({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + schedule: (flush) => flush(), + transport: { listScoped } + }) + return { cache, controller } +} + +/** Reads the module-level generation registry, which is the fence the renderer really runs. */ +function createFencedController(listScoped: AutomationHostSchedulerTransport['listScoped']) { + const cache = createAutomationHostCache({ connectionGeneration: () => 0 }) + const controller = createAutomationHostQueryController({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + schedule: (flush) => flush(), + transport: { listScoped } + }) + return { cache, controller } +} + +afterEach(() => { + resetAutomationHostCatalogGenerationsForTests() +}) + +describe('automation host query controller', () => { + it('advances catalog generations before fetching anything', async () => { + const listScoped = vi.fn(() => Promise.resolve(emptyList())) + const { controller } = createController(listScoped) + expect(getAuthorityCatalogGeneration(RUNTIME)).toBe(0) + + await controller.applyCatalog(catalogOf([entry(RUNTIME_SELF)])) + + expect(getAuthorityCatalogGeneration(RUNTIME)).toBe(1) + expect(listScoped).toHaveBeenCalledTimes(1) + controller.dispose() + }) + + it('skips removed entries and unreachable authorities but keeps the selected host', async () => { + const listScoped = vi.fn(() => Promise.resolve(emptyList())) + const { controller } = createController(listScoped) + + await controller.applyCatalog( + catalogOf([ + entry(DESKTOP_SELF), + entry(RUNTIME_SELF, { authorityHealth: 'unavailable' }), + entry(RUNTIME_SSH, { catalogState: 'removed' }) + ]) + ) + expect(listScoped).toHaveBeenCalledTimes(1) + + await controller.applyCatalog( + catalogOf([entry(DESKTOP_SELF), entry(RUNTIME_SELF, { authorityHealth: 'unavailable' })]), + { selectedStableKey: hostStableKey(RUNTIME_SELF), force: true } + ) + expect(listScoped).toHaveBeenCalledTimes(3) + controller.dispose() + }) + + // The ordinary startup shape: SSH hydration re-applies the catalog while the + // four-slot pool still holds a queue, and the hosts in that queue are the ones + // that would otherwise sit at `loading` with no request and no Retry. + it('re-fetches hosts whose queued work a second catalog apply cancelled', async () => { + const gates: ((value: ScopedAutomationList) => void)[] = [] + const queried: string[] = [] + let blocking = true + const listScoped = vi.fn((_authority, selector) => { + queried.push(selector.kind === 'ssh' ? selector.targetId : selector.kind) + if (!blocking) { + return Promise.resolve(emptyList()) + } + return new Promise((resolve) => gates.push(resolve)) + }) + const { controller } = createController(listScoped) + const catalog = catalogOf( + Array.from({ length: 10 }, (_, index) => + entry({ authority: RUNTIME, selector: { kind: 'ssh', targetId: `target-${index}` } }) + ) + ) + + const first = controller.applyCatalog(catalog) + await Promise.resolve() + expect(queried).toHaveLength(4) + + const second = controller.applyCatalog(catalog) + blocking = false + for (const resolve of gates) { + resolve(emptyList()) + } + await Promise.all([first, second]) + + expect(new Set(queried).size).toBe(10) + controller.dispose() + }) + + // Design doc: a manual All-host refresh bypasses TTL but leaves hosts already + // known to fail to their own Retry rather than dialling them again. + it('does not re-enqueue a permanently failed host on a manual refresh', async () => { + const listScoped = vi.fn(() => Promise.reject(new Error('permission_denied'))) + const { cache, controller } = createController(listScoped) + const catalog = catalogOf([entry(DESKTOP_SELF)]) + + await controller.applyCatalog(catalog) + expect(cache.get(DESKTOP_SELF)?.error).toMatchObject({ retryable: false }) + + await controller.applyCatalog(catalog, { force: true }) + expect(listScoped).toHaveBeenCalledTimes(1) + controller.dispose() + }) + + // doc:321 — a same-id re-adoption moves the registration generation and nothing + // else, so rows fetched under the old one must not survive the applied catalog. + it('re-fetches a host re-adopted under a new registration inside the TTL', async () => { + // One row per registration, so a cached row names the incarnation it came from. + const listScoped = vi.fn((_authority, selector) => + Promise.resolve( + listOf([ + `gen-${selector.kind === 'ssh' ? selector.expectedTargetGeneration : selector.kind}` + ]) + ) + ) + const { cache, controller } = createFencedController(listScoped) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SSH, { targetGeneration: 4 })])) + expect(listScoped).toHaveBeenCalledTimes(1) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SSH, { targetGeneration: 5 })])) + + expect(listScoped).toHaveBeenCalledTimes(2) + expect(listScoped.mock.calls[1]?.[1]).toMatchObject({ expectedTargetGeneration: 5 }) + expect(cache.get(DESKTOP_SSH)?.data.map((row) => row.automation.id)).toEqual(['gen-5']) + controller.dispose() + }) + + it('forgets entries the rebuilt catalog no longer lists', async () => { + const listScoped = vi.fn(() => Promise.resolve(listOf(['a']))) + const { cache, controller } = createController(listScoped) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SELF), entry(RUNTIME_SELF)])) + expect(cache.keys()).toHaveLength(2) + + await controller.applyCatalog(catalogOf([entry(DESKTOP_SELF)])) + expect(cache.keys()).toEqual([hostStableKey(DESKTOP_SELF)]) + controller.dispose() + }) + + // Why: a positive count is what tells the next catalog to show the authority's orphan entry. + it('reports the latest orphan count an authority returned', async () => { + const listScoped = vi.fn(() => + Promise.resolve({ automations: [], items: [], orphanCount: 3, invalidRows: 0 }) + ) + const { controller } = createController(listScoped) + expect(controller.authorityOrphanCount(RUNTIME)).toBeNull() + + await controller.applyCatalog(catalogOf([entry(RUNTIME_SELF)])) + + expect(controller.authorityOrphanCount(RUNTIME)).toBe(3) + expect(controller.authorityOrphanCount(DESKTOP)).toBeNull() + controller.dispose() + }) + + it('refetches only the entries an event invalidated, past the TTL', async () => { + const listScoped = vi.fn(() => Promise.resolve(listOf(['a']))) + const { controller } = createController(listScoped) + await controller.applyCatalog(catalogOf([entry(DESKTOP_SELF), entry(RUNTIME_SELF)])) + expect(listScoped).toHaveBeenCalledTimes(2) + + controller.handleAuthorityEvent({ authority: RUNTIME, selector: { kind: 'self' } }) + await Promise.resolve() + await Promise.resolve() + + expect(listScoped).toHaveBeenCalledTimes(3) + controller.dispose() + }) + + // Why: an event burst on an old runtime must still cost that host one unscoped call. + it('shares one legacy request across every entry a burst invalidated', async () => { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const listLegacy = vi.fn(() => Promise.resolve([])) + const controller = createAutomationHostQueryController({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + transport: { listLegacy } + }) + const legacy = { querySupport: 'legacy-unscoped' as const } + await controller.applyCatalog( + catalogOf([entry(RUNTIME_SELF, legacy), entry(RUNTIME_SSH, legacy)]) + ) + expect(listLegacy).toHaveBeenCalledTimes(1) + + controller.handleAuthorityEvent({ authority: RUNTIME, selector: { kind: 'self' } }) + controller.handleAuthorityEvent({ authority: RUNTIME, reason: 'definition' }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(listLegacy).toHaveBeenCalledTimes(2) + controller.dispose() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-cache-controller.ts b/src/renderer/src/components/automations/automation-host-cache-controller.ts new file mode 100644 index 00000000000..5e50c41a317 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-controller.ts @@ -0,0 +1,220 @@ +/** + * Binds a rebuilt host catalog to the cache, the scheduler, and the authority + * event stream. + * + * The catalog is the only thing that decides what may be fetched: applying one + * advances the affected authorities' generations first (so anything in flight + * under the old membership is discarded), evicts entries the catalog no longer + * lists, and only then asks for what is missing or stale. Authorities that are + * merely unreachable are left alone — an all-hosts refresh must never dial a + * connection the user did not ask for. + */ + +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { LegacyAutomationPartitionContext } from '../../../../shared/automation-legacy-list-partition' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef +} from '../../../../shared/automation-owner-ref' +import { syncAutomationHostCatalogGenerations } from './automation-host-catalog-generation' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import { + createAutomationHostInvalidation, + type AutomationAuthorityChangeEvent, + type AutomationHostInvalidation +} from './automation-host-invalidation' +import { subscribeAutomationHostInvalidation } from './automation-host-invalidation-window-events' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget, + type AutomationHostScheduler, + type AutomationHostSchedulerOptions +} from './automation-host-scheduler' + +export type AutomationHostQueryControllerOptions = { + legacyPartitionContext: ( + authority: StableAutomationAuthorityRef + ) => LegacyAutomationPartitionContext + cache?: AutomationHostCache + scheduler?: AutomationHostScheduler + transport?: AutomationHostSchedulerOptions['transport'] + now?: () => number + random?: () => number + isVisible?: () => boolean + schedule?: (flush: () => void) => void + scheduleRetry?: AutomationHostSchedulerOptions['scheduleRetry'] + /** Where `automationsChanged` arrives; `null` opts out for a caller that feeds events itself. */ + eventTarget?: EventTarget | null + /** Notified after each committed refresh cycle so a view can re-render. */ + onSettled?: () => void +} + +export type AutomationHostApplyCatalogOptions = { + /** The host the user is looking at; it and Desktop + Self jump the queue. */ + selectedStableKey?: string | null + /** Manual refresh: bypasses TTL for the authorities that are reachable. */ + force?: boolean +} + +export type AutomationHostQueryController = { + cache: AutomationHostCache + scheduler: AutomationHostScheduler + applyCatalog: ( + catalog: AutomationHostCatalog, + options?: AutomationHostApplyCatalogOptions + ) => Promise + /** Feeds one `automationsChanged` event in; bursts coalesce into one refresh. */ + handleAuthorityEvent: (event: AutomationAuthorityChangeEvent) => void + /** Latest orphan count the authority reported, so the next catalog can show its orphan entry. */ + authorityOrphanCount: (authority: StableAutomationAuthorityRef) => number | null + dispose: () => void +} + +function authorityRefFor(entry: AutomationHostCatalogEntry): AutomationAuthorityRef { + if (entry.owner) { + return entry.owner.authority + } + const authority = entry.stableRef.authority + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { + kind: 'runtime', + environmentId: authority.environmentId, + // Why: an unmirrored environment must fail the fence rather than borrow revision 0. + pairingRevision: getRuntimeEnvironmentRevision(authority.environmentId) ?? -1 + } +} + +function isFetchable(entry: AutomationHostCatalogEntry, selectedStableKey: string | null): boolean { + if (entry.catalogState === 'removed') { + return false + } + // A disconnected authority proves nothing about its rows, so it keeps the stale ones + // and a Reconnect action instead of being dialled by a background refresh. + return entry.authorityHealth !== 'unavailable' || entry.stableKey === selectedStableKey +} + +function toFetchTarget( + entry: AutomationHostCatalogEntry, + selectedStableKey: string | null +): AutomationHostFetchTarget { + return { + ref: entry.stableRef, + authority: authorityRefFor(entry), + owner: entry.owner, + querySupport: entry.querySupport, + priority: + entry.stableKey === selectedStableKey || + (entry.stableRef.authority.kind === 'desktop' && entry.stableRef.selector.kind === 'self') + } +} + +export function createAutomationHostQueryController( + options: AutomationHostQueryControllerOptions +): AutomationHostQueryController { + const cache = options.cache ?? createAutomationHostCache({ now: options.now }) + const scheduler = + options.scheduler ?? + createAutomationHostScheduler({ + cache, + legacyPartitionContext: options.legacyPartitionContext, + transport: options.transport, + now: options.now, + random: options.random, + isVisible: options.isVisible, + scheduleRetry: options.scheduleRetry + }) + let latest: AutomationHostCatalog | null = null + let selected: string | null = null + + const targetsFor = ( + catalog: AutomationHostCatalog, + include?: (entry: AutomationHostCatalogEntry) => boolean + ): AutomationHostFetchTarget[] => + catalog.entries + .filter((entry) => isFetchable(entry, selected) && (include?.(entry) ?? true)) + .map((entry) => toFetchTarget(entry, selected)) + + const refreshKeys = async (stableKeys: readonly string[]): Promise => { + const catalog = latest + if (!catalog) { + return + } + const wanted = new Set(stableKeys) + const targets = targetsFor(catalog, (entry) => wanted.has(entry.stableKey)) + if (targets.length > 0) { + // Forced: the event already proved these entries stale, so TTL must not swallow them. + await scheduler.refresh(targets, { force: true }) + options.onSettled?.() + } + } + + const invalidation: AutomationHostInvalidation = createAutomationHostInvalidation({ + cache, + schedule: options.schedule, + onInvalidated: (stableKeys) => { + void refreshKeys(stableKeys) + } + }) + + // Subscribed here rather than by the page: an unsubscribed controller silently + // stops refreshing on writes, which looks like stale data, not a missing wire. + const eventTarget = + options.eventTarget === undefined ? (globalThis.window ?? null) : options.eventTarget + const unsubscribe = eventTarget + ? subscribeAutomationHostInvalidation(invalidation.handle, eventTarget) + : null + + return { + cache, + scheduler, + applyCatalog: async (catalog, applyOptions = {}) => { + latest = catalog + selected = applyOptions.selectedStableKey ?? null + // Membership first: a request captured under the old membership must not commit. + const generations = syncAutomationHostCatalogGenerations(catalog) + // A re-adopted host keeps its stable key, so eviction below never sees it: + // only this drops rows the host's previous incarnation answered with. + for (const stableKey of generations.reincarnatedStableKeys) { + cache.discardIncarnation(stableKey) + } + for (const key of cache.keys()) { + if (!catalog.byStableKey.has(key)) { + cache.evict(key) + } + } + scheduler.cancelQueued() + const manual = applyOptions.force === true + // A manual refresh bypasses TTL, but a host already known to fail stays failed + // with its own Retry rather than being dialled again by the all-hosts button. + await scheduler.refresh(targetsFor(catalog), { + force: manual, + skipKnownFailures: manual + }) + options.onSettled?.() + }, + handleAuthorityEvent: invalidation.handle, + authorityOrphanCount: (authority) => { + let newest: { fetchedAt: number; orphanCount: number } | null = null + for (const key of cache.keysForAuthority(authority)) { + const entry = cache.getByKey(key) + if (!entry || entry.orphanCount === null || entry.fetchedAt === null) { + continue + } + if (!newest || entry.fetchedAt >= newest.fetchedAt) { + newest = { fetchedAt: entry.fetchedAt, orphanCount: entry.orphanCount } + } + } + return newest?.orphanCount ?? null + }, + dispose: () => { + unsubscribe?.() + invalidation.dispose() + scheduler.dispose() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-cache-error.ts b/src/renderer/src/components/automations/automation-host-cache-error.ts new file mode 100644 index 00000000000..41e7323e414 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-error.ts @@ -0,0 +1,114 @@ +/** + * Classifies a failed host query by code rather than by copy, and decides + * whether it may retry. + * + * The distinction that matters: a host that *proved* it cannot serve the + * contract (old server, malformed payload, changed owner) must not burn the + * retry budget, while an unreachable or slow host must. + */ + +import { hasRuntimeRpcErrorCode } from '@/runtime/runtime-rpc-client' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + stripAutomationOwnerConflictCode +} from '../../../../shared/automation-owner-conflict' +import { + AutomationHostScopeUnsupportedError, + AutomationListResponseError, + matchAutomationOwnerConflict +} from './automation-scoped-list-client' +import type { + AutomationHostQueryError, + AutomationHostQueryErrorCode +} from './automation-host-cache-types' + +export const AUTOMATION_HOST_RETRY_BASE_MS = 1_000 +export const AUTOMATION_HOST_RETRY_CAP_MS = 30_000 +export const AUTOMATION_HOST_MAX_ATTEMPTS = 3 + +const UNAVAILABLE_RPC_CODES = [ + 'runtime_unavailable', + 'remote_runtime_unavailable', + 'relay_quota_exceeded' +] +const TIMEOUT_RPC_CODES = ['timeout', 'runtime_timeout'] +const PERMISSION_RPC_CODES = ['permission_denied', 'unauthorized', 'forbidden'] +const INCOMPATIBLE_RPC_CODES = ['capability_unsupported', 'legacy_read_only'] +const INVALID_RPC_CODES = ['invalid_runtime_response', 'invalid_argument'] + +const RETRYABLE_CODES: ReadonlySet = new Set([ + 'authority_unavailable', + 'timeout', + 'unknown' +]) + +const CONFLICT_QUERY_CODES: Record = { + [AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged]: 'owner_changed', + [AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved]: 'target_removed', + [AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired]: 'incompatible', + [AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination]: 'invalid_response' +} + +function messageOf(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error) + return stripAutomationOwnerConflictCode(raw) +} + +function codeOf(error: unknown): AutomationHostQueryErrorCode { + if (error instanceof AutomationHostScopeUnsupportedError) { + return 'incompatible' + } + if (error instanceof AutomationListResponseError) { + return 'invalid_response' + } + const conflict = matchAutomationOwnerConflict(error) + if (conflict) { + return CONFLICT_QUERY_CODES[conflict] ?? 'unknown' + } + const matches = (codes: readonly string[]): boolean => + codes.some((code) => hasRuntimeRpcErrorCode(error, code)) + if (matches(UNAVAILABLE_RPC_CODES)) { + return 'authority_unavailable' + } + if (matches(TIMEOUT_RPC_CODES)) { + return 'timeout' + } + if (matches(PERMISSION_RPC_CODES)) { + return 'permission_denied' + } + if (matches(INCOMPATIBLE_RPC_CODES)) { + return 'incompatible' + } + if (matches(INVALID_RPC_CODES)) { + return 'invalid_response' + } + return 'unknown' +} + +export function classifyAutomationHostQueryError( + error: unknown, + options: { attempt: number; now: number; random?: () => number } +): AutomationHostQueryError { + const code = codeOf(error) + const retryable = RETRYABLE_CODES.has(code) && options.attempt < AUTOMATION_HOST_MAX_ATTEMPTS + return { + code, + message: messageOf(error), + retryable, + retryAt: retryable + ? options.now + automationHostRetryDelayMs(options.attempt, options.random) + : null + } +} + +/** Full jitter: an authority that dropped every client must not get them back in lockstep. */ +export function automationHostRetryDelayMs( + attempt: number, + random: () => number = Math.random +): number { + const ceiling = Math.min( + AUTOMATION_HOST_RETRY_CAP_MS, + AUTOMATION_HOST_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1) + ) + return Math.round(random() * ceiling) +} diff --git a/src/renderer/src/components/automations/automation-host-cache-health.test.ts b/src/renderer/src/components/automations/automation-host-cache-health.test.ts new file mode 100644 index 00000000000..a30bba193fa --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-health.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationHostCacheEntry, + AutomationHostQueryErrorCode +} from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { + automationHostEntryHealth, + automationHostLoadCounts, + withAutomationHostCacheHealth +} from './automation-host-cache-health' + +function entry( + overrides: Partial & Pick +): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: null, + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function cached(overrides: Partial): AutomationHostCacheEntry { + return { + data: [], + fetchedAt: null, + attempt: 0, + requestGeneration: 0, + catalogGeneration: 0, + request: null, + error: null, + orphanCount: null, + ...overrides + } +} + +function failure(code: AutomationHostQueryErrorCode): Pick { + return { error: { code, message: 'nope', retryable: true, retryAt: null } } +} + +const SELF = entry({ stableKey: 'host:desktop:self' }) + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((candidate) => [candidate.stableKey, candidate])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +describe('automation host cache health', () => { + it('maps a transport failure to unreachable and an old server to incompatible', () => { + expect( + automationHostEntryHealth(SELF, { + entry: () => cached(failure('authority_unavailable')) + }) + ).toBe('unavailable') + expect(automationHostEntryHealth(SELF, { entry: () => cached(failure('incompatible')) })).toBe( + 'incompatible' + ) + expect(automationHostEntryHealth(SELF, { entry: () => cached(failure('timeout')) })).toBe( + 'stale-error' + ) + }) + + it('distinguishes a first load from a refresh over rows already on screen', () => { + expect( + automationHostEntryHealth(SELF, { + entry: () => cached({ request: Promise.resolve() }) + }) + ).toBe('loading') + expect( + automationHostEntryHealth(SELF, { + entry: () => cached({ request: Promise.resolve(), fetchedAt: 5 }) + }) + ).toBe('refreshing') + }) + + it('marks a host whose unscoped list failed, so it never reads as empty', () => { + expect( + automationHostEntryHealth(SELF, { + entry: () => null, + failedAuthorityKeys: new Set(['authority:desktop']) + }) + ).toBe('stale-error') + }) + + it('lets a successful host-scoped read outrank the unscoped failure', () => { + // The per-host read is better evidence about this host than the ambient one. + expect( + automationHostEntryHealth(SELF, { + entry: () => cached({ fetchedAt: 5 }), + failedAuthorityKeys: new Set(['authority:desktop']) + }) + ).toBe('fresh') + }) + + it('returns the same catalog when nothing moved', () => { + const catalog = catalogOf([SELF]) + expect(withAutomationHostCacheHealth(catalog, { entry: () => null })).toBe(catalog) + }) + + it('counts failed hosts and excludes hosts that were never expected to load', () => { + const catalog = catalogOf([ + SELF, + entry({ stableKey: 'a', authorityHealth: 'unavailable' }), + entry({ stableKey: 'b', authorityHealth: 'stale-error' }), + entry({ stableKey: 'gone', catalogState: 'removed', authorityHealth: 'unavailable' }) + ]) + + expect(automationHostLoadCounts(catalog)).toEqual({ failedHostCount: 2, totalHostCount: 3 }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-cache-health.ts b/src/renderer/src/components/automations/automation-host-cache-health.ts new file mode 100644 index 00000000000..b84454bb4db --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-health.ts @@ -0,0 +1,132 @@ +/** + * Folds query results back onto the catalog for display. + * + * Kept separate from the catalog the controller is given: this one changes on + * every commit, and re-applying a catalog advances generations and cancels + * in-flight requests. The picker, notice, and empty state read this projection; + * the scheduler reads the store-derived one. + */ + +import type { AutomationHostCache } from './automation-host-cache' +import type { + AutomationHostCacheEntry, + AutomationHostQueryErrorCode +} from './automation-host-cache-types' +import type { + AutomationAuthorityHealth, + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' + +export type AutomationHostLoadCounts = { + failedHostCount: number + totalHostCount: number +} + +export type AutomationHostHealthInput = { + entry: (stableKey: string) => AutomationHostCacheEntry | null + /** + * Authorities whose most recent list attempt failed outside the per-host + * cache — today the page's own unscoped refresh. Without this a failed load + * would render as an empty host, which claims we looked and found nothing. + */ + failedAuthorityKeys?: ReadonlySet +} + +const ERROR_HEALTH: Partial> = { + authority_unavailable: 'unavailable', + incompatible: 'incompatible' +} + +function healthFromCache( + cached: AutomationHostCacheEntry | null +): AutomationAuthorityHealth | null { + if (!cached) { + return null + } + if (cached.error) { + return ERROR_HEALTH[cached.error.code] ?? 'stale-error' + } + if (cached.request) { + return cached.fetchedAt === null ? 'loading' : 'refreshing' + } + return cached.fetchedAt === null ? null : 'fresh' +} + +export function automationHostEntryHealth( + entry: AutomationHostCatalogEntry, + input: AutomationHostHealthInput +): AutomationAuthorityHealth { + // An offline authority already outranks anything its cache entry could say. + if (entry.authorityHealth === 'unavailable' || entry.authorityHealth === 'incompatible') { + return entry.authorityHealth + } + const fromCache = healthFromCache(input.entry(entry.stableKey)) + if (fromCache) { + return fromCache + } + const authorityKey = automationAuthorityCatalogKey(entry.stableRef.authority) + if (input.failedAuthorityKeys?.has(authorityKey)) { + return 'stale-error' + } + return entry.authorityHealth +} + +/** Returns the same catalog object when no entry's health moved, so memos hold. */ +export function withAutomationHostCacheHealth( + catalog: AutomationHostCatalog, + input: AutomationHostHealthInput +): AutomationHostCatalog { + let changed = false + const entries = catalog.entries.map((entry) => { + const authorityHealth = automationHostEntryHealth(entry, input) + if (authorityHealth === entry.authorityHealth) { + return entry + } + changed = true + return { ...entry, authorityHealth } + }) + if (!changed) { + return catalog + } + return { + entries, + byStableKey: new Map(entries.map((entry) => [entry.stableKey, entry])), + hydration: { + ...catalog.hydration, + unavailableAuthorityKeys: new Set([ + ...catalog.hydration.unavailableAuthorityKeys, + ...entries + .filter((entry) => entry.authorityHealth === 'unavailable') + .map((entry) => automationAuthorityCatalogKey(entry.stableRef.authority)) + ]) + } + } +} + +const FAILED_HEALTH: ReadonlySet = new Set([ + 'unavailable', + 'stale-error', + 'incompatible' +]) + +/** + * Counts hosts, not authorities: the summary tells the user how much of the + * list is missing, and one unreachable server can account for several hosts. + * Removed entries are excluded — a ghost was never expected to load. + */ +export function automationHostLoadCounts(catalog: AutomationHostCatalog): AutomationHostLoadCounts { + const counted = catalog.entries.filter((entry) => entry.catalogState !== 'removed') + return { + failedHostCount: counted.filter((entry) => FAILED_HEALTH.has(entry.authorityHealth)).length, + totalHostCount: counted.length + } +} + +/** Adapter so callers can pass a live cache where a plain reader is expected. */ +export function automationHostCacheReader( + cache: AutomationHostCache +): (stableKey: string) => AutomationHostCacheEntry | null { + return (stableKey) => cache.getByKey(stableKey) +} diff --git a/src/renderer/src/components/automations/automation-host-cache-rows.ts b/src/renderer/src/components/automations/automation-host-cache-rows.ts new file mode 100644 index 00000000000..9d8f2888efe --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-rows.ts @@ -0,0 +1,111 @@ +/** + * Turns an authority's answer into cache rows. + * + * Scoped rows keep the owner they were fetched under so every later action can + * fence on it. Legacy rows keep no owner and no usage: the old response proves + * neither, and the design forbids compensating with run-history downloads. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListItem } from '../../../../shared/automation-list-scope' +import { + partitionLegacyAutomationList, + type LegacyAutomationPartitionContext, + type LegacyAutomationSelector +} from '../../../../shared/automation-legacy-list-partition' +import type { + AutomationAuthorityRef, + AutomationOwnerRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { ScopedAutomationList } from './automation-scoped-list-client' +import type { AutomationHostRow } from './automation-host-cache-types' + +function ownerForItem( + authority: AutomationAuthorityRef, + item: AutomationListItem +): AutomationOwnerRef | null { + if (item.selector.kind === 'orphan') { + return null + } + return item.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: item.selector.targetId, + targetGeneration: item.selector.targetGeneration + } + } + : { authority, selector: { kind: 'self' } } +} + +export function toScopedAutomationHostRows( + authority: AutomationAuthorityRef, + result: ScopedAutomationList +): AutomationHostRow[] { + const itemsById = new Map(result.items.map((item) => [item.automationId, item])) + const rows: AutomationHostRow[] = [] + for (const automation of result.automations) { + const item = itemsById.get(automation.id) + if (!item) { + continue + } + const usageSummary = item.usageSummary ?? null + rows.push({ + automation, + owner: ownerForItem(authority, item), + selector: item.selector, + usageSummary, + // An absent or unreadable projection reads as unavailable rather than as zero usage. + usageKnown: usageSummary !== null + }) + } + return rows +} + +export type LegacyAutomationHostPartition = { + rowsByStableKey: Map + orphanCount: number +} + +function legacySelectorMatches( + selector: LegacyAutomationSelector, + ref: StableAutomationCatalogRef +): boolean { + if (selector.kind !== ref.selector.kind) { + return false + } + return selector.kind === 'ssh' && ref.selector.kind === 'ssh' + ? selector.targetId === ref.selector.targetId + : true +} + +/** + * Fans one unscoped response out to every entry that asked for it, so an old + * runtime is queried once per cycle rather than once per selector. + */ +export function partitionLegacyAutomationHostRows( + automations: readonly Automation[], + refs: readonly StableAutomationCatalogRef[], + context: LegacyAutomationPartitionContext, + keyOf: (ref: StableAutomationCatalogRef) => string +): LegacyAutomationHostPartition { + const partition = partitionLegacyAutomationList(automations, context) + const rowsByStableKey = new Map() + for (const ref of refs) { + rowsByStableKey.set( + keyOf(ref), + partition.rows + .filter((row) => legacySelectorMatches(row.selector, ref)) + .map((row) => ({ + automation: row.automation, + owner: null, + selector: row.selector, + usageSummary: null, + usageKnown: false + })) + ) + } + return { rowsByStableKey, orphanCount: partition.orphanCount } +} diff --git a/src/renderer/src/components/automations/automation-host-cache-types.ts b/src/renderer/src/components/automations/automation-host-cache-types.ts new file mode 100644 index 00000000000..29f0eab65f3 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache-types.ts @@ -0,0 +1,76 @@ +/** + * Shapes the per-host automation cache stores and fences. + * + * A cached row is only ever as trustworthy as the incarnation it was fetched + * under, so every row keeps the owner it came from (null when the authority + * could not qualify it) and every request keeps the three generations its + * commit must still match. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListItemSelector } from '../../../../shared/automation-list-scope' +import type { LegacyAutomationSelector } from '../../../../shared/automation-legacy-list-partition' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary' + +/** A legacy SSH row carries no registration generation, which is why it can never be fenced. */ +export type AutomationHostRowSelector = AutomationListItemSelector | LegacyAutomationSelector + +export type AutomationHostRow = { + automation: Automation + /** Null when nothing may be mutated through it: legacy unscoped rows and orphans. */ + owner: AutomationOwnerRef | null + selector: AutomationHostRowSelector + usageSummary: AutomationUsageSummary | null + /** False on the legacy path, where the authority sends no projection and none may be fetched. */ + usageKnown: boolean +} + +export type AutomationHostQueryErrorCode = + | 'authority_unavailable' + | 'timeout' + | 'permission_denied' + | 'incompatible' + | 'invalid_response' + | 'owner_changed' + | 'target_removed' + | 'unknown' + +export type AutomationHostQueryError = { + code: AutomationHostQueryErrorCode + message: string + retryable: boolean + /** Wall-clock time the next automatic attempt may start; null when nothing is scheduled. */ + retryAt: number | null +} + +export type AutomationHostCacheEntry = { + data: readonly AutomationHostRow[] + fetchedAt: number | null + attempt: number + requestGeneration: number + catalogGeneration: number + request: Promise | null + error: AutomationHostQueryError | null + /** Authoritative orphan count from the last scoped response; null when never reported. */ + orphanCount: number | null +} + +/** + * Everything a response must still match to commit. `catalogGeneration` covers + * removal tombstones too: a target leaving the catalog advances it. + */ +export type AutomationHostRequestFence = { + stableKey: string + authorityKey: string + requestGeneration: number + catalogGeneration: number + /** The authority's `pairingRevision`; a fixed constant for desktop. */ + connectionGeneration: number +} + +/** Desktop storage is local, so it has no pairing to revise. */ +export const DESKTOP_AUTHORITY_CONNECTION_GENERATION = 0 + +export const AUTOMATION_HOST_CACHE_TTL_MS = 30_000 +export const AUTOMATION_HOST_RETIRED_CACHE_LIMIT = 256 diff --git a/src/renderer/src/components/automations/automation-host-cache.test.ts b/src/renderer/src/components/automations/automation-host-cache.test.ts new file mode 100644 index 00000000000..b68f3144607 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache.test.ts @@ -0,0 +1,254 @@ +/** + * The commit fence is the whole point of this cache: an answer that was correct + * when it was asked for may be wrong by the time it lands, and rendering it + * would attribute one host's automations to another. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { + createAutomationCatalogGenerationRegistry, + type AutomationCatalogGenerationRegistry +} from './automation-host-catalog-generation' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry, + AutomationHostQuerySupport +} from './automation-host-catalog-types' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } +const RUNTIME_SSH: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' } +} + +function row(id: string): AutomationHostRow { + return { + automation: { id, name: id } as Automation, + owner: null, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false + } +} + +function createCache(overrides: Parameters[0] = {}) { + return createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0, + ...overrides + }) +} + +describe('automation host cache commit fence', () => { + it('commits a response whose generations all still match', () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(true) + expect(cache.get(DESKTOP_SELF)?.data.map((entry) => entry.automation.id)).toEqual(['a']) + }) + + it('discards a response whose entry was invalidated while it was in flight', () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + cache.invalidate(DESKTOP_SELF) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + expect(cache.get(DESKTOP_SELF)?.data).toEqual([]) + }) + + it('discards a response whose authority catalog generation advanced', () => { + let generation = 7 + const cache = createCache({ catalogGeneration: () => generation }) + const fence = cache.beginRequest(RUNTIME_SSH) + generation = 8 + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + }) + + it('discards a response from a runtime that re-paired mid-flight', () => { + let revision = 4 + const cache = createCache({ connectionGeneration: () => revision }) + const fence = cache.beginRequest(RUNTIME_SELF) + revision = 5 + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + expect( + cache.fail(fence, { code: 'timeout', message: 'x', retryable: true, retryAt: null }) + ).toBe(false) + }) + + // doc:321 — the stable key survives a re-adoption, so the rows have to go. + it('drops the rows and the in-flight marker of a superseded incarnation', () => { + const cache = createCache() + cache.commit(cache.beginRequest(RUNTIME_SSH), { rows: [row('a')], orphanCount: 2 }) + const stale = cache.beginRequest(RUNTIME_SSH) + cache.trackRequest(stale, Promise.resolve()) + + cache.discardIncarnation(hostStableKey(RUNTIME_SSH)) + + expect(cache.get(RUNTIME_SSH)?.data).toEqual([]) + expect(cache.freshness(RUNTIME_SSH)).toBe('missing') + expect(cache.get(RUNTIME_SSH)?.orphanCount).toBeNull() + expect(cache.pendingRequest(RUNTIME_SSH)).toBeNull() + expect(cache.commit(stale, { rows: [row('b')] })).toBe(false) + expect(cache.commit(cache.beginRequest(RUNTIME_SSH), { rows: [row('c')] })).toBe(true) + }) + + it('keeps the last good rows when a refresh fails and clears them on success', () => { + const cache = createCache() + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) + const failing = cache.beginRequest(DESKTOP_SELF) + cache.fail(failing, { code: 'timeout', message: 'slow', retryable: true, retryAt: 100 }) + expect(cache.get(DESKTOP_SELF)?.data).toHaveLength(1) + expect(cache.get(DESKTOP_SELF)?.attempt).toBe(1) + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a'), row('b')] }) + expect(cache.get(DESKTOP_SELF)?.error).toBeNull() + expect(cache.get(DESKTOP_SELF)?.attempt).toBe(0) + }) +}) + +describe('automation host cache freshness and sharing', () => { + it('reports fresh inside the TTL and stale past it', () => { + let clock = 1_000 + const cache = createCache({ now: () => clock, ttlMs: 30_000 }) + expect(cache.freshness(DESKTOP_SELF)).toBe('missing') + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [] }) + expect(cache.freshness(DESKTOP_SELF)).toBe('fresh') + clock += 30_001 + expect(cache.freshness(DESKTOP_SELF)).toBe('stale') + }) + + it('publishes one in-flight request for concurrent callers of the same owner', async () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + const request = Promise.resolve() + cache.trackRequest(fence, request) + expect(cache.pendingRequest(DESKTOP_SELF)).toBe(request) + cache.commit(fence, { rows: [] }) + expect(cache.pendingRequest(DESKTOP_SELF)).toBeNull() + }) + + it('refuses to publish an in-flight request against a superseded generation', () => { + const cache = createCache() + const fence = cache.beginRequest(DESKTOP_SELF) + cache.invalidate(DESKTOP_SELF) + cache.trackRequest(fence, Promise.resolve()) + expect(cache.pendingRequest(DESKTOP_SELF)).toBeNull() + }) +}) + +describe('automation host cache invalidation scope', () => { + it('invalidates only the entries of the named authority', () => { + const cache = createCache() + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) + const runtimeFence = cache.beginRequest(RUNTIME_SELF) + cache.commit(runtimeFence, { rows: [row('b')] }) + const desktopFence = cache.beginRequest(DESKTOP_SELF) + + expect(cache.invalidateAuthority(RUNTIME)).toEqual([hostStableKey(RUNTIME_SELF)]) + expect(cache.commit(desktopFence, { rows: [row('c')] })).toBe(true) + }) + + it('evicts a departed entry and caps the retired pool', () => { + const cache = createCache({ retiredLimit: 1 }) + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) + cache.commit(cache.beginRequest(RUNTIME_SELF), { rows: [row('b')] }) + cache.evict(hostStableKey(DESKTOP_SELF)) + cache.evict(hostStableKey(RUNTIME_SELF)) + expect(cache.keys()).toEqual([]) + // The revived entry keeps its advanced generation, so nothing captured before eviction commits. + const stale = cache.beginRequest(RUNTIME_SELF) + cache.invalidate(RUNTIME_SELF) + expect(cache.commit(stale, { rows: [] })).toBe(false) + }) + + it('notifies subscribers when entries change', () => { + const cache = createCache() + const listener = vi.fn() + const unsubscribe = cache.subscribe(listener) + cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [] }) + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + cache.invalidate(DESKTOP_SELF) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) + +function catalogEntry( + ref: StableAutomationCatalogRef, + querySupport: AutomationHostQuerySupport, + ownerRevision: number | null +): AutomationHostCatalogEntry { + return { + stableRef: ref, + owner: + ownerRevision === null + ? null + : { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: ownerRevision }, + selector: { kind: 'self' } + }, + stableKey: hostStableKey(ref), + label: 'Runtime', + authorityLabel: 'Runtime', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'unknown', + querySupport + } +} + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((entry) => [entry.stableKey, entry])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(['env-1']), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +/** + * The fence is only as stable as the catalog generation feeding it, so this + * covers Step 2's warning from the consumer's side. + */ +describe('offline authority generation churn', () => { + const withRegistry = (registry: AutomationCatalogGenerationRegistry) => + createAutomationHostCache({ + catalogGeneration: (authority) => registry.get(authority), + connectionGeneration: () => 0 + }) + + it('does not churn while a disconnected authority keeps its last known query support', () => { + const registry = createAutomationCatalogGenerationRegistry() + const cache = withRegistry(registry) + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'scoped', 3)])) + const fence = cache.beginRequest(RUNTIME_SELF) + // The authority went offline; membership and incarnation are unchanged. + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'scoped', 3)])) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(true) + }) + + it('churns if a disconnect is reported as legacy-unscoped with no owner', () => { + const registry = createAutomationCatalogGenerationRegistry() + const cache = withRegistry(registry) + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'scoped', 3)])) + const fence = cache.beginRequest(RUNTIME_SELF) + registry.sync(catalogOf([catalogEntry(RUNTIME_SELF, 'legacy-unscoped', null)])) + expect(cache.commit(fence, { rows: [row('a')] })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-cache.ts b/src/renderer/src/components/automations/automation-host-cache.ts new file mode 100644 index 00000000000..d8b3da1525b --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-cache.ts @@ -0,0 +1,303 @@ +/** + * Per-host automation cache with a three-part commit fence. + * + * A response may only land if the entry's request generation, the owning + * authority's catalog generation, and the authority's connection generation all + * still match what the request captured. That is what stops a slow answer from + * a re-paired runtime, a re-registered SSH target, or a host the user has since + * left from overwriting rows that now belong to a different incarnation. + * + * Removal tombstones need no separate check: a target leaving the catalog + * advances that authority's catalog generation, which the same fence rejects. + */ + +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { getAuthorityCatalogGeneration } from './automation-host-catalog-generation' +import { + AUTOMATION_HOST_CACHE_TTL_MS, + AUTOMATION_HOST_RETIRED_CACHE_LIMIT, + DESKTOP_AUTHORITY_CONNECTION_GENERATION, + type AutomationHostCacheEntry, + type AutomationHostQueryError, + type AutomationHostRequestFence, + type AutomationHostRow +} from './automation-host-cache-types' + +export type AutomationHostCacheFreshness = 'missing' | 'fresh' | 'stale' + +export type AutomationHostCommitInput = { + rows: readonly AutomationHostRow[] + /** Authoritative orphan count when the response carried one. */ + orphanCount?: number | null +} + +export type AutomationHostCacheOptions = { + now?: () => number + ttlMs?: number + retiredLimit?: number + catalogGeneration?: (authority: StableAutomationAuthorityRef) => number + connectionGeneration?: (authority: StableAutomationAuthorityRef) => number +} + +export type AutomationHostCache = { + get: (ref: StableAutomationCatalogRef) => AutomationHostCacheEntry | null + getByKey: (stableKey: string) => AutomationHostCacheEntry | null + freshness: (ref: StableAutomationCatalogRef) => AutomationHostCacheFreshness + /** Captures the generations a later commit must still match. */ + beginRequest: (ref: StableAutomationCatalogRef) => AutomationHostRequestFence + /** Publishes an in-flight promise so concurrent callers for the same owner share it. */ + trackRequest: (fence: AutomationHostRequestFence, request: Promise) => void + pendingRequest: (ref: StableAutomationCatalogRef) => Promise | null + /** Releases the published request for work that was dropped before it was sent. */ + abandonRequest: (fence: AutomationHostRequestFence) => void + commit: (fence: AutomationHostRequestFence, input: AutomationHostCommitInput) => boolean + fail: (fence: AutomationHostRequestFence, error: AutomationHostQueryError) => boolean + /** Advances the request generation so anything already in flight is discarded. */ + invalidate: (ref: StableAutomationCatalogRef) => void + /** Same fence bump for a replacement request, but it keeps the retry budget spent so far. */ + supersede: (ref: StableAutomationCatalogRef) => void + invalidateKey: (stableKey: string) => void + /** + * Drops what a host answered under a previous incarnation of itself, keeping + * its display slot. The stable key survives a re-adoption, so nothing else + * distinguishes those rows — and the TTL would otherwise report them fresh. + */ + discardIncarnation: (stableKey: string) => void + invalidateAuthority: (authority: StableAutomationAuthorityRef) => readonly string[] + keysForAuthority: (authority: StableAutomationAuthorityRef) => readonly string[] + evict: (stableKey: string) => void + keys: () => readonly string[] + subscribe: (listener: () => void) => () => void + reset: () => void +} + +type CacheRecord = AutomationHostCacheEntry & { + ref: StableAutomationCatalogRef + authorityKey: string +} + +function defaultConnectionGeneration(authority: StableAutomationAuthorityRef): number { + // Why: an environment the renderer has not mirrored yet must never look like a match. + return authority.kind === 'desktop' + ? DESKTOP_AUTHORITY_CONNECTION_GENERATION + : (getRuntimeEnvironmentRevision(authority.environmentId) ?? -1) +} + +function emptyRecord(ref: StableAutomationCatalogRef, catalogGeneration: number): CacheRecord { + return { + ref, + authorityKey: automationAuthorityCatalogKey(ref.authority), + data: [], + fetchedAt: null, + attempt: 0, + requestGeneration: 0, + catalogGeneration, + request: null, + error: null, + orphanCount: null + } +} + +export function createAutomationHostCache( + options: AutomationHostCacheOptions = {} +): AutomationHostCache { + const now = options.now ?? Date.now + const ttlMs = options.ttlMs ?? AUTOMATION_HOST_CACHE_TTL_MS + const retiredLimit = options.retiredLimit ?? AUTOMATION_HOST_RETIRED_CACHE_LIMIT + const catalogGenerationOf = options.catalogGeneration ?? getAuthorityCatalogGeneration + const connectionGenerationOf = options.connectionGeneration ?? defaultConnectionGeneration + const records = new Map() + const retired = new Map() + const listeners = new Set<() => void>() + + const notify = (): void => { + for (const listener of listeners) { + listener() + } + } + + const ensure = (ref: StableAutomationCatalogRef): CacheRecord => { + const key = hostStableKey(ref) + const existing = records.get(key) + if (existing) { + return existing + } + // A retired entry keeps its generations, so a host coming back cannot commit stale work. + const revived = retired.get(key) + if (revived) { + retired.delete(key) + records.set(key, revived) + return revived + } + const created = emptyRecord(ref, catalogGenerationOf(ref.authority)) + records.set(key, created) + return created + } + + const matchesFence = (record: CacheRecord, fence: AutomationHostRequestFence): boolean => + record.requestGeneration === fence.requestGeneration && + catalogGenerationOf(record.ref.authority) === fence.catalogGeneration && + connectionGenerationOf(record.ref.authority) === fence.connectionGeneration + + // Returns false for an evicted entry too: either way the answer arrived too + // late to land, and the caller counts that as a discard. + const settle = ( + fence: AutomationHostRequestFence, + apply: (record: CacheRecord) => void + ): boolean => { + const record = records.get(fence.stableKey) + if (!record || !matchesFence(record, fence)) { + return false + } + record.request = null + apply(record) + notify() + return true + } + + const bump = (record: CacheRecord, keepAttempts = false): void => { + record.requestGeneration += 1 + record.request = null + if (!keepAttempts) { + record.attempt = 0 + } + } + + return { + get: (ref) => records.get(hostStableKey(ref)) ?? null, + getByKey: (stableKey) => records.get(stableKey) ?? null, + freshness: (ref) => { + const record = records.get(hostStableKey(ref)) + if (!record || record.fetchedAt === null) { + return 'missing' + } + return now() - record.fetchedAt < ttlMs ? 'fresh' : 'stale' + }, + beginRequest: (ref) => { + const record = ensure(ref) + record.catalogGeneration = catalogGenerationOf(ref.authority) + return { + stableKey: hostStableKey(ref), + authorityKey: record.authorityKey, + requestGeneration: record.requestGeneration, + catalogGeneration: record.catalogGeneration, + connectionGeneration: connectionGenerationOf(ref.authority) + } + }, + trackRequest: (fence, request) => { + const record = records.get(fence.stableKey) + if (record && record.requestGeneration === fence.requestGeneration) { + record.request = request + } + }, + pendingRequest: (ref) => records.get(hostStableKey(ref))?.request ?? null, + abandonRequest: (fence) => { + const record = records.get(fence.stableKey) + if (!record || record.requestGeneration !== fence.requestGeneration) { + return + } + // Why bump rather than only clear the marker: the marker may not be published + // yet when the job is abandoned, and only a stale generation refuses it later. + // A left-behind marker reads as in flight and skips the host on every plan. + bump(record, true) + notify() + }, + commit: (fence, input) => + settle(fence, (record) => { + record.data = input.rows + record.fetchedAt = now() + record.attempt = 0 + record.error = null + if (input.orphanCount !== undefined && input.orphanCount !== null) { + record.orphanCount = input.orphanCount + } + }), + // Why: a failed refresh keeps the last good rows readable and records the error beside them. + fail: (fence, error) => + settle(fence, (record) => { + record.attempt += 1 + record.error = error + }), + invalidate: (ref) => { + bump(ensure(ref)) + notify() + }, + supersede: (ref) => { + bump(ensure(ref), true) + notify() + }, + invalidateKey: (stableKey) => { + const record = records.get(stableKey) + if (record) { + bump(record) + notify() + } + }, + discardIncarnation: (stableKey) => { + const record = records.get(stableKey) + if (!record) { + return + } + // Bumped as well as emptied: work captured under the old incarnation is + // still in flight, and its marker would otherwise skip the host forever. + bump(record) + record.data = [] + record.fetchedAt = null + record.error = null + record.orphanCount = null + notify() + }, + invalidateAuthority: (authority) => { + const authorityKey = automationAuthorityCatalogKey(authority) + const invalidated: string[] = [] + for (const [key, record] of records) { + if (record.authorityKey === authorityKey) { + bump(record) + invalidated.push(key) + } + } + if (invalidated.length > 0) { + notify() + } + return invalidated + }, + keysForAuthority: (authority) => { + const authorityKey = automationAuthorityCatalogKey(authority) + return [...records].filter(([, r]) => r.authorityKey === authorityKey).map(([key]) => key) + }, + evict: (stableKey) => { + const record = records.get(stableKey) + if (!record) { + return + } + bump(record) + records.delete(stableKey) + retired.set(stableKey, record) + // Insertion order is LRU order here: reviving re-inserts at the end. + while (retired.size > retiredLimit) { + const oldest = retired.keys().next() + if (oldest.done) { + break + } + retired.delete(oldest.value) + } + notify() + }, + keys: () => [...records.keys()], + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + reset: () => { + records.clear() + retired.clear() + notify() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts b/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts new file mode 100644 index 00000000000..f085311fda2 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-generation.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import { + createAutomationCatalogGenerationRegistry, + getAuthorityCatalogGeneration, + resetAutomationHostCatalogGenerationsForTests, + syncAutomationHostCatalogGenerations +} from './automation-host-catalog-generation' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalogInput, + AutomationRuntimeAuthorityInput +} from './automation-host-catalog-types' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const ENV_A: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-a' } +const ENV_B: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-b' } + +function mirror( + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function runtime( + environmentId: string, + overrides: Partial = {} +): AutomationRuntimeAuthorityInput { + return { + environmentId, + label: environmentId, + pairingRevision: 1, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror(), + ...overrides + } +} + +function input(overrides: Partial = {}): AutomationHostCatalogInput { + return { + desktop: { label: 'Local Mac', ssh: mirror() }, + runtimes: [runtime('env-a'), runtime('env-b')], + runtimeCatalogSettled: true, + ...overrides + } +} + +describe('automation catalog generation', () => { + let registry = createAutomationCatalogGenerationRegistry() + + beforeEach(() => { + registry = createAutomationCatalogGenerationRegistry() + }) + + it('advances every authority on the first sync', () => { + registry.sync(buildAutomationHostCatalog(input())) + expect(registry.get(DESKTOP)).toBe(1) + expect(registry.get(ENV_A)).toBe(1) + expect(registry.get(ENV_B)).toBe(1) + }) + + it('advances only the authority whose target bucket hydrated', () => { + registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { ssh: mirror({ targetsHydrated: false }) }), + runtime('env-b') + ] + }) + ) + ) + const before = { + desktop: registry.get(DESKTOP), + a: registry.get(ENV_A), + b: registry.get(ENV_B) + } + const advanced = registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { + ssh: mirror({ targets: [{ targetId: 't', label: 'T', generation: 2 }] }) + }), + runtime('env-b') + ] + }) + ) + ) + expect(advanced.advancedAuthorityKeys).toEqual(['authority:runtime:env-a']) + expect(registry.get(ENV_A)).toBe(before.a + 1) + expect(registry.get(ENV_B)).toBe(before.b) + expect(registry.get(DESKTOP)).toBe(before.desktop) + }) + + it('does not advance on runtime or SSH connection status changes', () => { + const targets = [{ targetId: 't', label: 'T', generation: 2 }] + registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { + ssh: mirror({ targets, connectionStatusByTargetId: new Map([['t', 'connected']]) }) + }), + runtime('env-b') + ] + }) + ) + ) + const before = registry.get(ENV_A) + const advanced = registry.sync( + buildAutomationHostCatalog( + input({ + runtimes: [ + runtime('env-a', { + authorityHealth: 'stale-error', + ssh: mirror({ targets, connectionStatusByTargetId: new Map([['t', 'reconnecting']]) }) + }), + runtime('env-b') + ] + }) + ) + ) + expect(advanced.advancedAuthorityKeys).toEqual([]) + expect(registry.get(ENV_A)).toBe(before) + }) + + it('does not advance on a rename', () => { + registry.sync(buildAutomationHostCatalog(input())) + const before = registry.get(ENV_A) + registry.sync( + buildAutomationHostCatalog( + input({ runtimes: [runtime('env-a', { label: 'Renamed' }), runtime('env-b')] }) + ) + ) + expect(registry.get(ENV_A)).toBe(before) + }) + + it('advances on a same-id re-pair', () => { + registry.sync(buildAutomationHostCatalog(input())) + const before = registry.get(ENV_A) + const advanced = registry.sync( + buildAutomationHostCatalog( + input({ runtimes: [runtime('env-a', { pairingRevision: 2 }), runtime('env-b')] }) + ) + ) + expect(registry.get(ENV_A)).toBe(before + 1) + expect(registry.get(ENV_B)).toBe(1) + expect(advanced.reincarnatedStableKeys).toEqual(['host:runtime:env-a:self']) + }) + + // doc:321 — the entry a same-id re-adoption produces is byte-identical apart + // from this, so the caller has no other way to know its rows are obsolete. + it('reports a same-id SSH re-adoption as a reincarnated host', () => { + const desktopAt = (generation: number, label = 'Box'): AutomationHostCatalogInput => + input({ + desktop: { + label: 'Local Mac', + ssh: mirror({ targets: [{ targetId: 'ssh-1', label, generation }] }) + } + }) + const first = registry.sync(buildAutomationHostCatalog(desktopAt(4))) + expect(first.reincarnatedStableKeys).toEqual([]) + + const renamed = registry.sync(buildAutomationHostCatalog(desktopAt(4, 'Renamed'))) + expect(renamed.reincarnatedStableKeys).toEqual([]) + + const readopted = registry.sync(buildAutomationHostCatalog(desktopAt(5))) + expect(readopted.reincarnatedStableKeys).toEqual(['host:desktop:ssh:ssh-1']) + expect(readopted.advancedAuthorityKeys).toEqual(['authority:desktop']) + }) + + // The generation file's own warning: an offline authority reported as + // legacy-unscoped loses its owner refs, which must not read as a re-adoption. + it('does not report a host whose owner merely went absent', () => { + const withSupport = (querySupport: 'scoped' | 'legacy-unscoped'): AutomationHostCatalogInput => + input({ + runtimes: [ + runtime('env-a', { + querySupport, + // An SSH entry is where the owner ref actually disappears with the support level. + ssh: mirror({ targets: [{ targetId: 't', label: 'T', generation: 3 }] }) + }) + ] + }) + registry.sync(buildAutomationHostCatalog(withSupport('scoped'))) + + const degraded = registry.sync(buildAutomationHostCatalog(withSupport('legacy-unscoped'))) + expect(degraded.reincarnatedStableKeys).toEqual([]) + + const restored = registry.sync(buildAutomationHostCatalog(withSupport('scoped'))) + expect(restored.reincarnatedStableKeys).toEqual([]) + }) + + it('advances once when an authority leaves the catalog and stays put afterwards', () => { + registry.sync(buildAutomationHostCatalog(input())) + registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-a')] }))) + const afterRemoval = registry.get(ENV_B) + expect(afterRemoval).toBe(2) + registry.sync(buildAutomationHostCatalog(input({ runtimes: [runtime('env-a')] }))) + expect(registry.get(ENV_B)).toBe(afterRemoval) + }) + + it('exposes a module-level registry for the cache fence', () => { + resetAutomationHostCatalogGenerationsForTests() + expect(getAuthorityCatalogGeneration(DESKTOP)).toBe(0) + syncAutomationHostCatalogGenerations(buildAutomationHostCatalog(input())) + expect(getAuthorityCatalogGeneration(DESKTOP)).toBe(1) + resetAutomationHostCatalogGenerationsForTests() + expect(getAuthorityCatalogGeneration(DESKTOP)).toBe(0) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog-generation.ts b/src/renderer/src/components/automations/automation-host-catalog-generation.ts new file mode 100644 index 00000000000..8db2c85c16c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-generation.ts @@ -0,0 +1,143 @@ +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationHostCatalog, + type AutomationHostCatalogEntry +} from './automation-host-catalog-types' + +/** + * Catalog generation, tracked *per authority*. + * + * It advances only when that authority's authoritative membership or + * incarnation changes. Runtime connection status and SSH connection status feed + * health, not this counter — otherwise one host's target bucket hydrating would + * discard every other host's in-flight response and burn the retry cap. + * + * Callers must report the last known query support for an offline authority: + * synthesising `legacy-unscoped` on a mere disconnect would drop the entries' + * owner refs and read as an incarnation change. + */ + +export type AutomationCatalogGenerationSync = { + /** Authority keys whose catalog generation advanced. */ + advancedAuthorityKeys: readonly string[] + /** + * Hosts that stayed listed while their owner incarnation changed underneath — + * a same-id SSH re-adoption or re-pair. Nothing else in the entry moves, so + * this is the only signal that the rows already held answer for a host that no + * longer exists. + */ + reincarnatedStableKeys: readonly string[] +} + +export type AutomationCatalogGenerationRegistry = { + get: (authority: StableAutomationAuthorityRef) => number + /** Applies a rebuilt catalog and reports what the caller has to invalidate. */ + sync: (catalog: AutomationHostCatalog) => AutomationCatalogGenerationSync + reset: () => void +} + +// Health is deliberately absent: only membership and incarnation belong here. +export function automationHostCatalogEntryFingerprint(entry: AutomationHostCatalogEntry): string { + return `${entry.stableKey}|${entry.catalogState}|${entry.owner ? ownerKey(entry.owner) : '-'}` +} + +function fingerprintByAuthority(catalog: AutomationHostCatalog): Map { + const parts = new Map() + for (const entry of catalog.entries) { + const key = automationAuthorityCatalogKey(entry.stableRef.authority) + const bucket = parts.get(key) + if (bucket) { + bucket.push(automationHostCatalogEntryFingerprint(entry)) + } else { + parts.set(key, [automationHostCatalogEntryFingerprint(entry)]) + } + } + // Entries arrive in the deterministic catalog order, so the join is stable. + return new Map([...parts].map(([key, values]) => [key, values.join('\n')])) +} + +export function createAutomationCatalogGenerationRegistry(): AutomationCatalogGenerationRegistry { + const generationByAuthorityKey = new Map() + const fingerprintByAuthorityKey = new Map() + const ownerKeyByStableKey = new Map() + + /** Compared only where an owner exists: a disconnect can strip owner refs, and that is not a new incarnation. */ + const reincarnations = (catalog: AutomationHostCatalog): string[] => { + const reincarnated: string[] = [] + const listed = new Set() + for (const entry of catalog.entries) { + listed.add(entry.stableKey) + if (!entry.owner) { + continue + } + const next = ownerKey(entry.owner) + const previous = ownerKeyByStableKey.get(entry.stableKey) + if (previous !== undefined && previous !== next) { + reincarnated.push(entry.stableKey) + } + ownerKeyByStableKey.set(entry.stableKey, next) + } + // Deleting during iteration is defined for Map; a visited key is never revisited. + for (const stableKey of ownerKeyByStableKey.keys()) { + if (!listed.has(stableKey)) { + ownerKeyByStableKey.delete(stableKey) + } + } + return reincarnated + } + + const advance = (authorityKey: string, fingerprint: string): void => { + fingerprintByAuthorityKey.set(authorityKey, fingerprint) + generationByAuthorityKey.set( + authorityKey, + (generationByAuthorityKey.get(authorityKey) ?? 0) + 1 + ) + } + + return { + get: (authority) => generationByAuthorityKey.get(automationAuthorityCatalogKey(authority)) ?? 0, + sync: (catalog) => { + const reincarnatedStableKeys = reincarnations(catalog) + const next = fingerprintByAuthority(catalog) + const advanced: string[] = [] + for (const [authorityKey, fingerprint] of next) { + if (fingerprintByAuthorityKey.get(authorityKey) !== fingerprint) { + advance(authorityKey, fingerprint) + advanced.push(authorityKey) + } + } + // Why: an authority that left the catalog must invalidate its captured requests too. + for (const authorityKey of fingerprintByAuthorityKey.keys()) { + if (!next.has(authorityKey) && fingerprintByAuthorityKey.get(authorityKey) !== '') { + advance(authorityKey, '') + advanced.push(authorityKey) + } + } + return { advancedAuthorityKeys: advanced, reincarnatedStableKeys } + }, + reset: () => { + generationByAuthorityKey.clear() + fingerprintByAuthorityKey.clear() + ownerKeyByStableKey.clear() + } + } +} + +const registry = createAutomationCatalogGenerationRegistry() + +/** Generation Step 4's commit fence compares for the authority that owns the entry. */ +export function getAuthorityCatalogGeneration(authority: StableAutomationAuthorityRef): number { + return registry.get(authority) +} + +export function syncAutomationHostCatalogGenerations( + catalog: AutomationHostCatalog +): AutomationCatalogGenerationSync { + return registry.sync(catalog) +} + +export function resetAutomationHostCatalogGenerationsForTests(): void { + registry.reset() +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-order.test.ts b/src/renderer/src/components/automations/automation-host-catalog-order.test.ts new file mode 100644 index 00000000000..347b55d2d06 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-order.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' + +function mirror( + targets: { targetId: string; label: string }[], + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: targets.map((target) => ({ ...target, generation: 1 })), + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function describeEntry(entry: AutomationHostCatalogEntry): string { + const authority = entry.stableRef.authority + const prefix = authority.kind === 'desktop' ? 'desktop' : authority.environmentId + return `${prefix}/${entry.kind}:${entry.label}` +} + +describe('orderAutomationHostCatalogEntries', () => { + it('orders desktop first, then runtimes, with orphans last inside each authority', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror([ + { targetId: 'z', label: 'zeta' }, + { targetId: 'a', label: 'alpha' } + ]), + orphanCount: 1 + }, + runtimes: [ + { + environmentId: 'env-z', + label: 'Zulu', + pairingRevision: 1, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror([{ targetId: 'zt', label: 'zulu target' }]), + orphanCount: 4 + }, + { + environmentId: 'env-a', + label: 'Alpha', + pairingRevision: 1, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror([ + { targetId: 'b2', label: 'beta' }, + { targetId: 'a2', label: 'Ampere' } + ]) + } + ], + runtimeCatalogSettled: true + }) + expect(catalog.entries.map(describeEntry)).toEqual([ + 'desktop/self:Local Mac', + 'desktop/ssh:alpha', + 'desktop/ssh:zeta', + 'desktop/orphan:Unassigned legacy automations', + 'env-a/self:Alpha', + 'env-a/ssh:Ampere', + 'env-a/ssh:beta', + 'env-z/self:Zulu', + 'env-z/ssh:zulu target', + 'env-z/orphan:Unassigned legacy automations' + ]) + }) + + it('breaks equal labels by target id and equal authority labels by environment id', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror([ + { targetId: 'zzz', label: 'same' }, + { targetId: 'aaa', label: 'same' } + ]) + }, + runtimes: ['env-b', 'env-a'].map((environmentId) => ({ + environmentId, + label: 'Tied', + pairingRevision: 1, + authorityHealth: 'fresh' as const, + querySupport: 'scoped' as const, + ssh: mirror([]) + })), + runtimeCatalogSettled: true + }) + expect(catalog.entries.map(describeEntry)).toEqual([ + 'desktop/self:Local Mac', + 'desktop/ssh:same', + 'desktop/ssh:same', + 'env-a/self:Tied', + 'env-b/self:Tied' + ]) + const desktopSsh = catalog.entries.filter((entry) => entry.kind === 'ssh') + expect(desktopSsh.map((entry) => entry.stableRef.selector)).toEqual([ + { kind: 'ssh', targetId: 'aaa' }, + { kind: 'ssh', targetId: 'zzz' } + ]) + }) + + it('sorts labels with a locale-aware collator', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror([ + { targetId: '1', label: 'box-10' }, + { targetId: '2', label: 'box-2' }, + { targetId: '3', label: 'Ähnlich' } + ]) + }, + runtimes: [], + runtimeCatalogSettled: true + }) + expect( + catalog.entries.filter((entry) => entry.kind === 'ssh').map((entry) => entry.label) + ).toEqual(['Ähnlich', 'box-2', 'box-10']) + }) + + it('constructs exactly one collator per rebuild', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { + label: 'Local Mac', + ssh: mirror( + Array.from({ length: 40 }, (_unused, index) => ({ + targetId: `t${index}`, + label: `host ${index}` + })) + ) + }, + runtimes: [], + runtimeCatalogSettled: true + }) + const createCollator = vi.fn(() => new Intl.Collator(undefined, { numeric: true })) + orderAutomationHostCatalogEntries(catalog.entries, { createCollator }) + expect(createCollator).toHaveBeenCalledTimes(1) + }) + + it('is a pure reordering', () => { + const catalog = buildAutomationHostCatalog({ + desktop: { label: 'Local Mac', ssh: mirror([{ targetId: 'a', label: 'A' }]) }, + runtimes: [], + runtimeCatalogSettled: true + }) + const input = catalog.entries.toReversed() + const ordered = orderAutomationHostCatalogEntries(input) + expect(input).toEqual(catalog.entries.toReversed()) + expect(new Set(ordered)).toEqual(new Set(catalog.entries)) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog-order.ts b/src/renderer/src/components/automations/automation-host-catalog-order.ts new file mode 100644 index 00000000000..b07223e6e75 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-order.ts @@ -0,0 +1,76 @@ +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +/** + * The one deterministic catalog order, shared by the picker and the All-hosts + * grouped list so responses arriving out of order never reflow the page. + * + * Sort fields are precomputed once per rebuild and a single `Intl.Collator` is + * constructed per call — never one per comparison. + */ + +export type AutomationHostCatalogOrderOptions = { + /** Injectable so tests can prove exactly one collator is built per rebuild. */ + createCollator?: () => Intl.Collator +} + +type SortFields = { + entry: AutomationHostCatalogEntry + authorityRank: number + authorityLabel: string + authorityId: string + kindRank: number + label: string + targetId: string +} + +const KIND_RANK: Record = { + self: 0, + ssh: 1, + orphan: 2 +} + +function defaultCollator(): Intl.Collator { + return new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }) +} + +function toSortFields(entry: AutomationHostCatalogEntry): SortFields { + const { authority, selector } = entry.stableRef + return { + entry, + authorityRank: authority.kind === 'desktop' ? 0 : 1, + authorityLabel: entry.authorityLabel, + authorityId: authority.kind === 'desktop' ? '' : authority.environmentId, + kindRank: KIND_RANK[entry.kind], + label: entry.label, + targetId: selector.kind === 'ssh' ? selector.targetId : '' + } +} + +// Why: ids are identifiers, not prose — compare them byte-wise so the tiebreak is locale-independent. +function compareIds(a: string, b: string): number { + return a === b ? 0 : a < b ? -1 : 1 +} + +export function orderAutomationHostCatalogEntries( + entries: readonly AutomationHostCatalogEntry[], + options?: AutomationHostCatalogOrderOptions +): AutomationHostCatalogEntry[] { + const collator = (options?.createCollator ?? defaultCollator)() + const decorated = entries.map(toSortFields) + decorated.sort((a, b) => { + if (a.authorityRank !== b.authorityRank) { + return a.authorityRank - b.authorityRank + } + if (a.authorityId !== b.authorityId) { + return ( + collator.compare(a.authorityLabel, b.authorityLabel) || + compareIds(a.authorityId, b.authorityId) + ) + } + if (a.kindRank !== b.kindRank) { + return a.kindRank - b.kindRank + } + return collator.compare(a.label, b.label) || compareIds(a.targetId, b.targetId) + }) + return decorated.map((fields) => fields.entry) +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-source.test.ts b/src/renderer/src/components/automations/automation-host-catalog-source.test.ts new file mode 100644 index 00000000000..7104026fe6a --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-source.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + buildAutomationHostCatalogSource, + runtimeAuthorityHealth, + runtimeQuerySupport, + type AutomationCatalogRuntimeSource, + type AutomationCatalogSshSource +} from './automation-host-catalog-source' + +function runtime( + overrides: Partial = {} +): AutomationCatalogRuntimeSource { + return { + environmentId: 'gpu', + label: 'GPU box', + pairingRevision: 7, + status: undefined, + ssh: undefined, + ...overrides + } +} + +const EMPTY_SSH: AutomationCatalogSshSource = { + targetsHydrated: true, + targetLabels: new Map(), + removedTargetLabels: new Map(), + connectionStates: new Map() +} + +describe('automation host catalog source', () => { + it('treats a never-probed environment as loading, not as down', () => { + expect(runtimeAuthorityHealth(runtime())).toBe('loading') + expect(runtimeAuthorityHealth(runtime({ status: { status: null } }))).toBe('unavailable') + expect(runtimeAuthorityHealth(runtime({ status: { status: {} as never } }))).toBe('fresh') + }) + + it('assumes scoped support until the capability set is actually known', () => { + // The scoped client re-checks the capability and fails closed itself, so + // guessing legacy here would only discard owners on a modern host. + expect(runtimeQuerySupport(runtime())).toBe('scoped') + expect(runtimeQuerySupport(runtime({ status: { status: null } }))).toBe('scoped') + expect( + runtimeQuerySupport(runtime({ status: { status: { capabilities: [] } as never } })) + ).toBe('legacy-unscoped') + expect( + runtimeQuerySupport( + runtime({ + status: { + status: { capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] } as never + } + }) + ) + ).toBe('scoped') + }) + + it('assumes a hydrated desktop target with no recorded state is disconnected', () => { + const input = buildAutomationHostCatalogSource({ + desktopSsh: { + ...EMPTY_SSH, + targetLabels: new Map([['t1', 'web-01']]), + targetGenerations: new Map([['t1', 3]]) + }, + runtimes: [], + runtimeCatalogSettled: true + }) + + expect(input.desktop.ssh.missingConnectionStatus).toBe('disconnected') + expect(input.desktop.ssh.targets).toEqual([{ targetId: 't1', label: 'web-01', generation: 3 }]) + }) + + it('omits a generation the authority never reported rather than inventing one', () => { + const input = buildAutomationHostCatalogSource({ + desktopSsh: { ...EMPTY_SSH, targetLabels: new Map([['t1', 'web-01']]) }, + runtimes: [], + runtimeCatalogSettled: true + }) + + expect(input.desktop.ssh.targets[0]).toEqual({ targetId: 't1', label: 'web-01' }) + }) + + it('omits an unsettled orphan count instead of passing zero', () => { + const settled = buildAutomationHostCatalogSource({ + desktopSsh: EMPTY_SSH, + runtimes: [], + runtimeCatalogSettled: true, + orphanCount: () => 0 + }) + const unsettled = buildAutomationHostCatalogSource({ + desktopSsh: EMPTY_SSH, + runtimes: [], + runtimeCatalogSettled: true, + orphanCount: () => null + }) + + expect(settled.desktop.orphanCount).toBe(0) + expect('orphanCount' in unsettled.desktop).toBe(false) + }) + + it('leaves a runtime with no mirrored SSH state unhydrated', () => { + const input = buildAutomationHostCatalogSource({ + desktopSsh: EMPTY_SSH, + runtimes: [runtime()], + runtimeCatalogSettled: true + }) + + // Absence of a bucket is not evidence the environment has no targets. + expect(input.runtimes[0].ssh.targetsHydrated).toBe(false) + expect(input.runtimes[0].pairingRevision).toBe(7) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog-source.ts b/src/renderer/src/components/automations/automation-host-catalog-source.ts new file mode 100644 index 00000000000..670fe4b175c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-source.ts @@ -0,0 +1,154 @@ +/** + * Projects mirrored renderer state into the catalog builder's input. + * + * Deliberately reads store state only. Query results are folded in later by + * `automation-host-cache-health.ts`, because a catalog derived from the cache + * would change every time the cache it drives commits, and re-applying a catalog + * is what advances generations and cancels in-flight work. + */ + +import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { + AutomationAuthorityHealth, + AutomationCatalogSshMirrorInput, + AutomationCatalogSshTargetInput, + AutomationHostCatalogInput, + AutomationHostQuerySupport +} from './automation-host-catalog-types' + +/** The mirrored SSH state one authority exposes; desktop and runtime shapes converge here. */ +export type AutomationCatalogSshSource = { + targetsHydrated: boolean + targetLabels: ReadonlyMap + /** Absent for a target whose authority never reported a registration generation. */ + targetGenerations?: ReadonlyMap + removedTargetLabels: ReadonlyMap + connectionStates: ReadonlyMap +} + +export type AutomationCatalogRuntimeSource = { + environmentId: string + label: string + pairingRevision: number + /** Undefined until this client has probed the environment at all. */ + status: { status: RuntimeStatus | null } | undefined + ssh: AutomationCatalogSshSource | undefined +} + +export type AutomationHostCatalogSource = { + desktopLabel?: string + desktopSsh: AutomationCatalogSshSource + runtimes: readonly AutomationCatalogRuntimeSource[] + runtimeCatalogSettled: boolean + referencedStableKeys?: Iterable + /** Authoritative orphan counts the cache has seen, keyed by authority catalog key. */ + orphanCount?: (authority: StableAutomationAuthorityRef) => number | null +} + +const EMPTY_SSH: AutomationCatalogSshSource = { + targetsHydrated: false, + targetLabels: new Map(), + removedTargetLabels: new Map(), + connectionStates: new Map() +} + +function toSshTargets(source: AutomationCatalogSshSource): AutomationCatalogSshTargetInput[] { + const targets: AutomationCatalogSshTargetInput[] = [] + for (const [targetId, label] of source.targetLabels) { + const generation = source.targetGenerations?.get(targetId) + targets.push({ targetId, label, ...(generation === undefined ? {} : { generation }) }) + } + return targets +} + +function toSshMirror( + source: AutomationCatalogSshSource, + missingConnectionStatus?: SshConnectionStatus +): AutomationCatalogSshMirrorInput { + const connectionStatusByTargetId = new Map() + for (const [targetId, state] of source.connectionStates) { + connectionStatusByTargetId.set(targetId, state.status) + } + return { + targetsHydrated: source.targetsHydrated, + targets: toSshTargets(source), + removedTargetLabels: source.removedTargetLabels, + connectionStatusByTargetId, + ...(missingConnectionStatus ? { missingConnectionStatus } : {}) + } +} + +/** A never-probed environment is loading, not down; only a failed probe proves unreachable. */ +export function runtimeAuthorityHealth( + runtime: AutomationCatalogRuntimeSource +): AutomationAuthorityHealth { + if (!runtime.status) { + return 'loading' + } + return runtime.status.status === null ? 'unavailable' : 'fresh' +} + +/** + * Optimistic on an unknown capability set, fail-closed on a known one: the + * scoped client re-checks the capability before every list and raises the + * update-required error itself, so guessing `legacy-unscoped` here would only + * throw away owners on a modern host we have not probed yet. + */ +export function runtimeQuerySupport( + runtime: AutomationCatalogRuntimeSource +): AutomationHostQuerySupport { + const capabilities = runtime.status?.status?.capabilities + if (!capabilities) { + return 'scoped' + } + return capabilities.includes(AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY) + ? 'scoped' + : 'legacy-unscoped' +} + +function orphanCountFor( + source: AutomationHostCatalogSource, + authority: StableAutomationAuthorityRef +): number | undefined { + return source.orphanCount?.(authority) ?? undefined +} + +export function buildAutomationHostCatalogSource( + source: AutomationHostCatalogSource +): AutomationHostCatalogInput { + return { + desktop: { + label: source.desktopLabel ?? getLocalExecutionHostLabel(), + // Why 'disconnected' for a hydrated target with no state: the desktop + // records a state the moment it dials, so silence means it never did. + ssh: toSshMirror(source.desktopSsh, 'disconnected'), + ...withOrphanCount(orphanCountFor(source, { kind: 'desktop' })) + }, + runtimes: source.runtimes.map((runtime) => { + const authority: StableAutomationAuthorityRef = { + kind: 'runtime', + environmentId: runtime.environmentId + } + return { + environmentId: runtime.environmentId, + label: runtime.label, + pairingRevision: runtime.pairingRevision, + authorityHealth: runtimeAuthorityHealth(runtime), + querySupport: runtimeQuerySupport(runtime), + ssh: toSshMirror(runtime.ssh ?? EMPTY_SSH), + ...withOrphanCount(orphanCountFor(source, authority)) + } + }), + runtimeCatalogSettled: source.runtimeCatalogSettled, + ...(source.referencedStableKeys ? { referencedStableKeys: source.referencedStableKeys } : {}) + } +} + +/** Omitted rather than passed as undefined: absence is what marks the count unsettled. */ +function withOrphanCount(orphanCount: number | undefined): { orphanCount?: number } { + return orphanCount === undefined ? {} : { orphanCount } +} diff --git a/src/renderer/src/components/automations/automation-host-catalog-types.ts b/src/renderer/src/components/automations/automation-host-catalog-types.ts new file mode 100644 index 00000000000..4aa37d0617c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog-types.ts @@ -0,0 +1,141 @@ +import type { + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +/** + * Health of an authority's *stored-automation query path*. Never describes its + * execution targets: a reachable authority keeps listing and editing records + * whose SSH target is down (required invariant 6). + */ +export type AutomationAuthorityHealth = + | 'loading' + | 'fresh' + | 'refreshing' + | 'stale-error' + | 'unavailable' + | 'incompatible' + +/** Health of the *execution target*, tracked independently of authority health. */ +export type AutomationExecutionHealth = + | 'connected' + | 'connecting' + | 'disconnected' + | 'unavailable' + | 'unknown' + +/** What list/fencing contract this entry can use. `legacy-unscoped` entries are view-only. */ +export type AutomationHostQuerySupport = 'scoped' | 'legacy-unscoped' | 'incompatible' + +/** + * Why an entry cannot use host-scoped, fenced queries. Absent when it can. + * + * Only `authority-unscoped` is positive evidence about the authority's list + * contract — a probe that actually came back without the capability. The target + * reasons say nothing about the server's age and none of them are repaired by + * updating it, so they must never share its badge copy or its recovery verb. + */ +export type AutomationHostScopeGap = + /** The owning authority proved the target is gone (tombstone or hydrated absence). */ + | 'target-removed' + /** No live registration has been seen yet; a disconnect clears generations too. */ + | 'target-unverified' + /** The target is live and hydrated, but its authority issued it no registration generation. */ + | 'target-unregistered' + /** The authority advertises no host-scoped list capability. */ + | 'authority-unscoped' + +/** Whether the entry's membership is proven, merely remembered, or positively gone. */ +export type AutomationHostCatalogState = 'authoritative' | 'unhydrated' | 'removed' + +export type AutomationHostCatalogEntry = { + stableRef: StableAutomationCatalogRef + /** Null whenever no executable, fully fenced owner exists (ghost, unhydrated, orphan, legacy). */ + owner: AutomationOwnerRef | null + stableKey: string + label: string + authorityLabel: string + kind: 'self' | 'ssh' | 'orphan' + catalogState: AutomationHostCatalogState + authorityHealth: AutomationAuthorityHealth + executionHealth: AutomationExecutionHealth + querySupport: AutomationHostQuerySupport + /** Present only while `querySupport` is degraded; explains which repair, if any, applies. */ + scopeGap?: AutomationHostScopeGap +} + +export type AutomationCatalogSshTargetInput = { + targetId: string + label: string + /** Durable registration generation; absent on legacy targets and old servers. */ + generation?: number +} + +/** One authority's mirrored SSH state. Desktop and runtime supply the same shape. */ +export type AutomationCatalogSshMirrorInput = { + /** Absence only counts as removal once a target list actually loaded. */ + targetsHydrated: boolean + targets: readonly AutomationCatalogSshTargetInput[] + removedTargetLabels: ReadonlyMap + connectionStatusByTargetId: ReadonlyMap + /** Status assumed for a hydrated target with no recorded state; desktop passes `disconnected`. */ + missingConnectionStatus?: SshConnectionStatus +} + +export type AutomationDesktopAuthorityInput = { + label: string + /** Desktop storage is local IPC, so this reflects query/cache state only. */ + authorityHealth?: AutomationAuthorityHealth + ssh: AutomationCatalogSshMirrorInput + /** Authoritative orphan count from the last list; absent means not yet known. */ + orphanCount?: number +} + +export type AutomationRuntimeAuthorityInput = { + environmentId: string + label: string + pairingRevision: number + authorityHealth: AutomationAuthorityHealth + /** Supplied by the caller from advertised capabilities; the projection never probes. */ + querySupport: AutomationHostQuerySupport + ssh: AutomationCatalogSshMirrorInput + orphanCount?: number +} + +export type AutomationHostCatalogInput = { + desktop: AutomationDesktopAuthorityInput + /** Saved runtime environments — projected even while offline. */ + runtimes: readonly AutomationRuntimeAuthorityInput[] + /** Gates Runtime+Self absence; a failed catalog read settles without hydrating. */ + runtimeCatalogSettled: boolean + /** Stable keys still referenced by a stored automation, cached row, persisted filter, or tombstone. */ + referencedStableKeys?: Iterable +} + +/** Positive-evidence gates for absence. Missing data is never removal (invariant 4). */ +export type AutomationCatalogHydrationEvidence = { + runtimeCatalogSettled: boolean + desktopSshHydrated: boolean + runtimeSshHydratedByEnvironmentId: ReadonlyMap + savedRuntimeEnvironmentIds: ReadonlySet + /** Authority keys that returned an authoritative orphan count. */ + orphanSettledAuthorityKeys: ReadonlySet + /** Authority keys currently unreachable; their absences prove nothing. */ + unavailableAuthorityKeys: ReadonlySet +} + +export type AutomationHostCatalog = { + /** Deterministically ordered; see `orderAutomationHostCatalogEntries`. */ + entries: readonly AutomationHostCatalogEntry[] + byStableKey: ReadonlyMap + hydration: AutomationCatalogHydrationEvidence +} + +/** Namespaced so an authority key can never be confused with one of its host keys. */ +export function automationAuthorityCatalogKey(authority: StableAutomationAuthorityRef): string { + return authority.kind === 'desktop' + ? 'authority:desktop' + : `authority:runtime:${encodeURIComponent(authority.environmentId)}` +} diff --git a/src/renderer/src/components/automations/automation-host-catalog.test.ts b/src/renderer/src/components/automations/automation-host-catalog.test.ts new file mode 100644 index 00000000000..2eae8530b02 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, it, vi } from 'vitest' +import { hostStableKey, ownerKey } from '../../../../shared/automation-owner-key' +import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../../../shared/execution-host' +import { + AUTOMATION_ORPHAN_ENTRY_LABEL, + buildAutomationHostCatalog +} from './automation-host-catalog' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalog, + AutomationHostCatalogEntry, + AutomationHostCatalogInput, + AutomationRuntimeAuthorityInput +} from './automation-host-catalog-types' + +function mirror( + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function runtime( + overrides: Partial = {} +): AutomationRuntimeAuthorityInput { + return { + environmentId: 'env-1', + label: 'Build server', + pairingRevision: 7, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror(), + ...overrides + } +} + +function buildInput( + overrides: Partial = {} +): AutomationHostCatalogInput { + return { + desktop: { label: 'Local Mac', ssh: mirror({ missingConnectionStatus: 'disconnected' }) }, + runtimes: [], + runtimeCatalogSettled: true, + ...overrides + } +} + +function entryAt(catalog: AutomationHostCatalog, key: string): AutomationHostCatalogEntry { + const entry = catalog.byStableKey.get(key) + if (!entry) { + throw new Error(`missing catalog entry: ${key}`) + } + return entry +} + +const DESKTOP_SELF_KEY = hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +}) + +function desktopSshKey(targetId: string): string { + return hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }) +} + +function runtimeSelfKey(environmentId: string): string { + return hostStableKey({ + authority: { kind: 'runtime', environmentId }, + selector: { kind: 'self' } + }) +} + +function runtimeSshKey(environmentId: string, targetId: string): string { + return hostStableKey({ + authority: { kind: 'runtime', environmentId }, + selector: { kind: 'ssh', targetId } + }) +} + +describe('buildAutomationHostCatalog', () => { + it('projects Desktop + Self as immediately authoritative', () => { + const catalog = buildAutomationHostCatalog(buildInput()) + const self = entryAt(catalog, DESKTOP_SELF_KEY) + expect(self).toMatchObject({ + kind: 'self', + label: 'Local Mac', + catalogState: 'authoritative', + executionHealth: 'connected', + querySupport: 'scoped' + }) + expect(self.owner).toEqual({ authority: { kind: 'desktop' }, selector: { kind: 'self' } }) + }) + + it('keeps equal SSH target ids under Desktop and a runtime distinct', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ + targets: [{ targetId: 'shared', label: 'Desktop box', generation: 3 }], + connectionStatusByTargetId: new Map([['shared', 'connected']]) + }) + }, + runtimes: [ + runtime({ + ssh: mirror({ + targets: [{ targetId: 'shared', label: 'Runtime box', generation: 11 }], + connectionStatusByTargetId: new Map([['shared', 'disconnected']]) + }) + }) + ] + }) + ) + const desktop = entryAt(catalog, desktopSshKey('shared')) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'shared')) + expect(desktop.stableKey).not.toBe(nested.stableKey) + expect(desktop.label).toBe('Desktop box') + expect(nested.label).toBe('Runtime box') + expect(desktop.executionHealth).toBe('connected') + expect(nested.executionHealth).toBe('disconnected') + expect(ownerKey(desktop.owner!)).not.toBe(ownerKey(nested.owner!)) + expect(nested.owner).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 7 }, + selector: { kind: 'ssh', targetId: 'shared', targetGeneration: 11 } + }) + }) + + it('keeps equal SSH target ids under two runtimes distinct', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + environmentId: 'env-a', + label: 'A', + ssh: mirror({ targets: [{ targetId: 'shared', label: 'A box', generation: 1 }] }) + }), + runtime({ + environmentId: 'env-b', + label: 'B', + ssh: mirror({ targets: [{ targetId: 'shared', label: 'B box', generation: 1 }] }) + }) + ] + }) + ) + expect(entryAt(catalog, runtimeSshKey('env-a', 'shared')).label).toBe('A box') + expect(entryAt(catalog, runtimeSshKey('env-b', 'shared')).label).toBe('B box') + expect(catalog.entries.filter((entry) => entry.kind === 'ssh')).toHaveLength(2) + }) + + it('projects Runtime + Self while the runtime is offline', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ runtimes: [runtime({ authorityHealth: 'unavailable' })] }) + ) + const self = entryAt(catalog, runtimeSelfKey('env-1')) + expect(self).toMatchObject({ + catalogState: 'authoritative', + authorityHealth: 'unavailable', + executionHealth: 'disconnected', + label: 'Build server' + }) + expect(self.owner).not.toBeNull() + }) + + it('preserves nested labels through a parent outage without claiming removal', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + authorityHealth: 'unavailable', + ssh: mirror({ + targetsHydrated: false, + targets: [{ targetId: 'nested', label: 'Nested box', generation: 4 }] + }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'nested')) + expect(nested.label).toBe('Nested box') + expect(nested.catalogState).toBe('unhydrated') + expect(nested.executionHealth).toBe('unknown') + // Unverified state must never carry a fencing owner. + expect(nested.owner).toBeNull() + }) + + it('never copies nested runtime targets into the desktop authority', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: 'only-nested', label: 'N', generation: 2 }] }) + }) + ] + }) + ) + expect(catalog.byStableKey.has(desktopSshKey('only-nested'))).toBe(false) + expect( + catalog.entries.filter((entry) => entry.stableRef.authority.kind === 'desktop') + ).toHaveLength(1) + }) + + it('hides runtime-owned ephemeral SSH targets from both authorities', () => { + const ephemeral = `${RUNTIME_OWNED_SSH_TARGET_ID_PREFIX}vm-1` + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ + targets: [{ targetId: ephemeral, label: 'VM', generation: 1 }], + removedTargetLabels: new Map([[ephemeral, 'VM']]) + }) + }, + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: ephemeral, label: 'VM', generation: 1 }] }) + }) + ], + referencedStableKeys: [desktopSshKey(ephemeral), runtimeSshKey('env-1', ephemeral)] + }) + ) + expect(catalog.entries.some((entry) => entry.kind === 'ssh')).toBe(false) + }) + + it('marks a tombstoned target removed and keeps its last known label', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + } + }) + ) + const ghost = entryAt(catalog, desktopSshKey('gone')) + expect(ghost).toMatchObject({ + label: 'Old box', + catalogState: 'removed', + executionHealth: 'unavailable', + owner: null + }) + }) + + it('treats absence as removal only once the target list hydrated', () => { + const referenced = [desktopSshKey('referenced')] + const hydrated = buildAutomationHostCatalog(buildInput({ referencedStableKeys: referenced })) + expect(entryAt(hydrated, desktopSshKey('referenced')).catalogState).toBe('removed') + + const unhydrated = buildAutomationHostCatalog( + buildInput({ + desktop: { label: 'Local Mac', ssh: mirror({ targetsHydrated: false }) }, + referencedStableKeys: referenced + }) + ) + const entry = entryAt(unhydrated, desktopSshKey('referenced')) + expect(entry.catalogState).toBe('unhydrated') + expect(entry.executionHealth).toBe('unknown') + }) + + it('keeps a legacy runtime view-only: no generations, no owners', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + querySupport: 'legacy-unscoped', + ssh: mirror({ targets: [{ targetId: 'legacy', label: 'Legacy box' }] }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'legacy')) + expect(nested.querySupport).toBe('legacy-unscoped') + expect(nested.owner).toBeNull() + // Self needs no target generation, so it stays owned even on an old server. + expect(entryAt(catalog, runtimeSelfKey('env-1')).owner).not.toBeNull() + }) + + it('downgrades a scoped entry whose target carries no generation', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [runtime({ ssh: mirror({ targets: [{ targetId: 'ungenerated', label: 'U' }] }) })] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'ungenerated')) + expect(nested.querySupport).toBe('legacy-unscoped') + expect(nested.owner).toBeNull() + }) + + it('blames the removed target, not a server, for a desktop ghost entry', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + }, + referencedStableKeys: [desktopSshKey('gone')] + }) + ) + const ghost = entryAt(catalog, desktopSshKey('gone')) + expect({ catalogState: ghost.catalogState, scopeGap: ghost.scopeGap }).toEqual({ + catalogState: 'removed', + scopeGap: 'target-removed' + }) + }) + + it('marks a modern runtime unverified, not unscoped, while its SSH bucket is stale', () => { + // markEnvironmentSshStateStale keeps labels but drops generations and hydration. + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + querySupport: 'scoped', + ssh: mirror({ targetsHydrated: false, targets: [{ targetId: 'box', label: 'Box' }] }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'box')) + expect(nested.scopeGap).toBe('target-unverified') + expect(nested.owner).toBeNull() + }) + + it('keeps target execution health independent of authority query health', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ + targets: [{ targetId: 'down', label: 'Down box', generation: 5 }], + connectionStatusByTargetId: new Map([['down', 'auth-failed']]) + }) + }) + ] + }) + ) + const nested = entryAt(catalog, runtimeSshKey('env-1', 'down')) + expect(nested.executionHealth).toBe('disconnected') + expect(nested.authorityHealth).toBe('fresh') + expect(nested.owner).not.toBeNull() + }) + + it('adds an orphan entry for a positive count and omits an empty one', () => { + expect( + buildAutomationHostCatalog(buildInput()).entries.some((entry) => entry.kind === 'orphan') + ).toBe(false) + expect( + buildAutomationHostCatalog( + buildInput({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 0 } }) + ).entries.some((entry) => entry.kind === 'orphan') + ).toBe(false) + + const catalog = buildAutomationHostCatalog( + buildInput({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 2 } }) + ) + const orphan = catalog.entries.find((entry) => entry.kind === 'orphan') + expect(orphan).toMatchObject({ + label: AUTOMATION_ORPHAN_ENTRY_LABEL, + catalogState: 'authoritative', + executionHealth: 'unavailable', + owner: null + }) + }) + + it('keeps a referenced orphan entry unhydrated until its authority answers', () => { + const key = hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }) + const catalog = buildAutomationHostCatalog(buildInput({ referencedStableKeys: [key] })) + expect(entryAt(catalog, key).catalogState).toBe('unhydrated') + }) + + it('preserves the stable key and owner slot across a rename', () => { + const before = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: 't1', label: 'Old name', generation: 9 }] }) + }) + ] + }) + ) + const after = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + label: 'Renamed server', + ssh: mirror({ targets: [{ targetId: 't1', label: 'New name', generation: 9 }] }) + }) + ] + }) + ) + const key = runtimeSshKey('env-1', 't1') + expect(entryAt(after, key).stableKey).toBe(entryAt(before, key).stableKey) + expect(ownerKey(entryAt(after, key).owner!)).toBe(ownerKey(entryAt(before, key).owner!)) + expect(entryAt(after, key).label).toBe('New name') + }) + + it('reports hydration evidence for every absence gate', () => { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimeCatalogSettled: false, + desktop: { label: 'Local Mac', ssh: mirror({ targetsHydrated: false }), orphanCount: 1 }, + runtimes: [ + runtime({ authorityHealth: 'unavailable', ssh: mirror({ targetsHydrated: false }) }), + runtime({ environmentId: 'env-2', label: 'Second' }) + ] + }) + ) + expect(catalog.hydration.runtimeCatalogSettled).toBe(false) + expect(catalog.hydration.desktopSshHydrated).toBe(false) + expect(catalog.hydration.savedRuntimeEnvironmentIds).toEqual(new Set(['env-1', 'env-2'])) + expect(catalog.hydration.runtimeSshHydratedByEnvironmentId.get('env-1')).toBe(false) + expect(catalog.hydration.runtimeSshHydratedByEnvironmentId.get('env-2')).toBe(true) + expect([...catalog.hydration.orphanSettledAuthorityKeys]).toEqual(['authority:desktop']) + expect([...catalog.hydration.unavailableAuthorityKeys]).toEqual(['authority:runtime:env-1']) + }) + + it('ignores unparseable referenced keys instead of throwing', () => { + expect(() => + buildAutomationHostCatalog(buildInput({ referencedStableKeys: ['nonsense', ''] })) + ).not.toThrow() + }) + + it('performs no network, runtime, or SSH side effects', () => { + const fetchSpy = vi.fn() + const socketSpy = vi.fn() + const globals = globalThis as Record + const previous = { fetch: globals.fetch, WebSocket: globals.WebSocket, window: globals.window } + globals.fetch = fetchSpy + globals.WebSocket = socketSpy + globals.window = new Proxy( + {}, + { + get: (_target, property) => { + throw new Error(`projection touched window.${String(property)}`) + } + } + ) + try { + const catalog = buildAutomationHostCatalog( + buildInput({ + runtimes: [ + runtime({ + ssh: mirror({ targets: [{ targetId: 't', label: 'T', generation: 1 }] }), + orphanCount: 3 + }) + ] + }) + ) + expect(catalog.entries.length).toBeGreaterThan(0) + } finally { + globals.fetch = previous.fetch + globals.WebSocket = previous.WebSocket + globals.window = previous.window + } + expect(fetchSpy).not.toHaveBeenCalled() + expect(socketSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts new file mode 100644 index 00000000000..71e445e546c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-catalog.ts @@ -0,0 +1,303 @@ +import { hostStableKey, parseHostStableKey } from '../../../../shared/automation-owner-key' +import type { + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' +import { + automationAuthorityCatalogKey, + type AutomationCatalogSshMirrorInput, + type AutomationHostCatalog, + type AutomationHostCatalogEntry, + type AutomationHostCatalogInput, + type AutomationHostCatalogState, + type AutomationHostQuerySupport, + type AutomationHostScopeGap, + type AutomationAuthorityHealth +} from './automation-host-catalog-types' +import { resolveSelfExecutionHealth, resolveSshExecutionHealth } from './automation-host-health' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' + +/** + * Purely projective host catalog for the Automations page. + * + * It reads state that is already mirrored on this client and returns entries. + * It never connects to a runtime, lists targets, or opens an SSH session — + * rendering the picker must cost nothing. Capability support and authority + * health are *inputs*, not probes. + */ + +export const AUTOMATION_ORPHAN_ENTRY_LABEL = 'Unassigned legacy automations' + +type ReferencedAuthorityScope = { targetIds: Set; orphan: boolean } + +type AuthorityProjectionContext = { + authority: StableAutomationAuthorityRef + authorityLabel: string + authorityHealth: AutomationAuthorityHealth + querySupport: AutomationHostQuerySupport + /** Null for the desktop authority, which has no pairing incarnation. */ + pairingRevision: number | null + ssh: AutomationCatalogSshMirrorInput + referenced: ReferencedAuthorityScope + orphanCount: number | undefined +} + +const EMPTY_SCOPE: ReferencedAuthorityScope = { targetIds: new Set(), orphan: false } + +function groupReferencedKeys( + keys: Iterable | undefined +): ReadonlyMap { + const grouped = new Map() + for (const key of keys ?? []) { + const ref = parseHostStableKey(key) + if (!ref) { + continue + } + const authorityKey = automationAuthorityCatalogKey(ref.authority) + let scope = grouped.get(authorityKey) + if (!scope) { + scope = { targetIds: new Set(), orphan: false } + grouped.set(authorityKey, scope) + } + if (ref.selector.kind === 'ssh') { + scope.targetIds.add(ref.selector.targetId) + } else if (ref.selector.kind === 'orphan') { + scope.orphan = true + } + } + return grouped +} + +function toOwner( + ctx: AuthorityProjectionContext, + selector: AutomationOwnerRef['selector'] +): AutomationOwnerRef { + return { + authority: + ctx.pairingRevision === null || ctx.authority.kind === 'desktop' + ? { kind: 'desktop' } + : { + kind: 'runtime', + environmentId: ctx.authority.environmentId, + pairingRevision: ctx.pairingRevision + }, + selector + } +} + +function makeEntry( + stableRef: StableAutomationCatalogRef, + entry: Omit +): AutomationHostCatalogEntry { + return { ...entry, stableRef, stableKey: hostStableKey(stableRef) } +} + +/** Omitted rather than passed as undefined: absence is what marks the entry fully scoped. */ +function withScopeGap(scopeGap: AutomationHostScopeGap | undefined): { + scopeGap?: AutomationHostScopeGap +} { + return scopeGap === undefined ? {} : { scopeGap } +} + +/** The authority's own contract; the only gap an entry with no target can have. */ +function authorityScopeGap( + querySupport: AutomationHostQuerySupport +): AutomationHostScopeGap | undefined { + return querySupport === 'legacy-unscoped' ? 'authority-unscoped' : undefined +} + +function projectSelfEntry(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry { + return makeEntry( + { authority: ctx.authority, selector: { kind: 'self' } }, + { + owner: toOwner(ctx, { kind: 'self' }), + label: ctx.authorityLabel, + authorityLabel: ctx.authorityLabel, + kind: 'self', + catalogState: 'authoritative', + authorityHealth: ctx.authorityHealth, + executionHealth: resolveSelfExecutionHealth(ctx.authorityHealth), + querySupport: ctx.querySupport, + ...withScopeGap(authorityScopeGap(ctx.querySupport)) + } + ) +} + +function resolveSshCatalogState( + isLive: boolean, + isTombstoned: boolean, + targetsHydrated: boolean +): AutomationHostCatalogState { + if (isLive) { + return targetsHydrated ? 'authoritative' : 'unhydrated' + } + // Why: a tombstone is positive evidence recorded by the owning authority; a + // bare absence only counts once that authority's target list actually loaded. + return isTombstoned || targetsHydrated ? 'removed' : 'unhydrated' +} + +/** + * A missing generation has four unrelated causes and only one of them is the + * server's contract. Reading them all as "old server" is exactly what + * `automation-host-catalog-generation.ts` warns callers against. + */ +function resolveSshScopeGap( + querySupport: AutomationHostQuerySupport, + catalogState: AutomationHostCatalogState, + generation: number | undefined +): AutomationHostScopeGap | undefined { + if (querySupport !== 'scoped') { + return authorityScopeGap(querySupport) + } + if (catalogState === 'removed') { + return 'target-removed' + } + // Checked before the generation: a stale bucket drops generations on a mere disconnect. + if (catalogState === 'unhydrated') { + return 'target-unverified' + } + return generation === undefined ? 'target-unregistered' : undefined +} + +function projectSshEntries(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry[] { + const liveTargets = new Map( + ctx.ssh.targets + .filter((target) => !isRuntimeOwnedSshTargetId(target.targetId)) + .map((target) => [target.targetId, target]) + ) + const candidateIds = new Set(liveTargets.keys()) + for (const targetId of ctx.ssh.removedTargetLabels.keys()) { + candidateIds.add(targetId) + } + for (const targetId of ctx.referenced.targetIds) { + candidateIds.add(targetId) + } + const entries: AutomationHostCatalogEntry[] = [] + for (const targetId of candidateIds) { + if (isRuntimeOwnedSshTargetId(targetId)) { + continue + } + const live = liveTargets.get(targetId) + const catalogState = resolveSshCatalogState( + live !== undefined, + ctx.ssh.removedTargetLabels.has(targetId), + ctx.ssh.targetsHydrated + ) + const generation = live?.generation + const scoped = ctx.querySupport === 'scoped' && generation !== undefined + entries.push( + makeEntry( + { authority: ctx.authority, selector: { kind: 'ssh', targetId } }, + { + owner: + scoped && catalogState === 'authoritative' && generation !== undefined + ? toOwner(ctx, { kind: 'ssh', targetId, targetGeneration: generation }) + : null, + label: live?.label ?? ctx.ssh.removedTargetLabels.get(targetId) ?? targetId, + authorityLabel: ctx.authorityLabel, + kind: 'ssh', + catalogState, + authorityHealth: ctx.authorityHealth, + executionHealth: resolveSshExecutionHealth( + catalogState, + ctx.ssh.connectionStatusByTargetId.get(targetId), + ctx.ssh.missingConnectionStatus + ), + // Why: without a registration generation the entry keys on target id alone, so it stays view-only. + querySupport: + ctx.querySupport === 'scoped' && !scoped ? 'legacy-unscoped' : ctx.querySupport, + ...withScopeGap(resolveSshScopeGap(ctx.querySupport, catalogState, generation)) + } + ) + ) + } + return entries +} + +function projectOrphanEntry(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry | null { + const orphanCount = ctx.orphanCount + const settled = orphanCount !== undefined + if (!ctx.referenced.orphan && !(orphanCount !== undefined && orphanCount > 0)) { + return null + } + return makeEntry( + { authority: ctx.authority, selector: { kind: 'orphan' } }, + { + owner: null, + label: AUTOMATION_ORPHAN_ENTRY_LABEL, + authorityLabel: ctx.authorityLabel, + kind: 'orphan', + catalogState: settled ? 'authoritative' : 'unhydrated', + authorityHealth: ctx.authorityHealth, + executionHealth: 'unavailable', + querySupport: ctx.querySupport, + ...withScopeGap(authorityScopeGap(ctx.querySupport)) + } + ) +} + +function projectAuthority(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry[] { + const orphan = projectOrphanEntry(ctx) + return [projectSelfEntry(ctx), ...projectSshEntries(ctx), ...(orphan ? [orphan] : [])] +} + +export function buildAutomationHostCatalog( + input: AutomationHostCatalogInput +): AutomationHostCatalog { + const referenced = groupReferencedKeys(input.referencedStableKeys) + const desktopAuthority: StableAutomationAuthorityRef = { kind: 'desktop' } + const contexts: AuthorityProjectionContext[] = [ + { + authority: desktopAuthority, + authorityLabel: input.desktop.label, + authorityHealth: input.desktop.authorityHealth ?? 'fresh', + querySupport: 'scoped', + pairingRevision: null, + ssh: input.desktop.ssh, + referenced: referenced.get(automationAuthorityCatalogKey(desktopAuthority)) ?? EMPTY_SCOPE, + orphanCount: input.desktop.orphanCount + }, + ...input.runtimes.map((runtime): AuthorityProjectionContext => { + const authority: StableAutomationAuthorityRef = { + kind: 'runtime', + environmentId: runtime.environmentId + } + return { + authority, + authorityLabel: runtime.label, + authorityHealth: runtime.authorityHealth, + querySupport: runtime.querySupport, + pairingRevision: runtime.pairingRevision, + ssh: runtime.ssh, + referenced: referenced.get(automationAuthorityCatalogKey(authority)) ?? EMPTY_SCOPE, + orphanCount: runtime.orphanCount + } + }) + ] + const entries = orderAutomationHostCatalogEntries(contexts.flatMap(projectAuthority)) + const byStableKey = new Map(entries.map((entry) => [entry.stableKey, entry])) + return { + entries, + byStableKey, + hydration: { + runtimeCatalogSettled: input.runtimeCatalogSettled, + desktopSshHydrated: input.desktop.ssh.targetsHydrated, + runtimeSshHydratedByEnvironmentId: new Map( + input.runtimes.map((runtime) => [runtime.environmentId, runtime.ssh.targetsHydrated]) + ), + savedRuntimeEnvironmentIds: new Set(input.runtimes.map((runtime) => runtime.environmentId)), + orphanSettledAuthorityKeys: new Set( + contexts + .filter((ctx) => ctx.orphanCount !== undefined) + .map((ctx) => automationAuthorityCatalogKey(ctx.authority)) + ), + unavailableAuthorityKeys: new Set( + contexts + .filter((ctx) => ctx.authorityHealth === 'unavailable') + .map((ctx) => automationAuthorityCatalogKey(ctx.authority)) + ) + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index e9a5bc03bc2..7a3aaaa8d49 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' +import type { Automation } from '../../../../shared/automations-types' import { - createAutomationForTarget, - getAutomationListTarget, + listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, updateAutomationForTarget @@ -68,11 +67,13 @@ describe('automation host client', () => { vi.clearAllMocks() }) - it('lists automations from the active remote server when one is selected', async () => { + it('lists automations from the host it was handed, which the caller must state', async () => { vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ automations: [makeAutomation()] }) - const target = getAutomationListTarget({ activeRuntimeEnvironmentId: 'gpu' }) - const automations = await listAutomationsForTarget(target) + const automations = await listAutomationsForTarget({ + kind: 'environment', + environmentId: 'gpu' + }) expect(automations).toHaveLength(1) expect(mockApi.automations.list).not.toHaveBeenCalled() @@ -84,45 +85,34 @@ describe('automation host client', () => { ) }) - it('creates and manually runs runtime-host automations through that server', async () => { - const automation = makeAutomation() - const input: AutomationCreateInput = { - name: automation.name, - prompt: automation.prompt, - precheck: null, - agentId: automation.agentId, - runContext: automation.runContext, - projectId: automation.projectId, - workspaceMode: automation.workspaceMode, - workspaceId: null, - setupDecision: 'run', - timezone: automation.timezone, - rrule: automation.rrule, - dtstart: automation.dtstart - } - vi.mocked(callRuntimeRpc) - .mockResolvedValueOnce({ automation }) - .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + // The broad form is gone in both directions: usage totals come from the + // authority's list projection, so nothing may ask a host for all of its runs. + it('fetches one automation history at a time, on both the desktop and a runtime', async () => { + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ runs: [] }) + mockApi.automations.listRuns.mockResolvedValueOnce([]) - await createAutomationForTarget(input) - await runAutomationNowForTarget(automation) + await listAutomationRunsForTarget({ kind: 'environment', environmentId: 'gpu' }, 'auto-1') + await listAutomationRunsForTarget({ kind: 'local' }, 'auto-1') - expect(mockApi.automations.create).not.toHaveBeenCalled() - expect(mockApi.automations.runNow).not.toHaveBeenCalled() - expect(callRuntimeRpc).toHaveBeenNthCalledWith( - 1, + expect(callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'gpu' }, - 'automation.create', - expect.objectContaining({ - repo: 'repo-1', - workspace: undefined, - setupDecision: 'run', - runContext: automation.runContext - }), + 'automation.runs', + { automationId: 'auto-1' }, { timeoutMs: 15_000 } ) - expect(callRuntimeRpc).toHaveBeenNthCalledWith( - 2, + expect(mockApi.automations.listRuns).toHaveBeenCalledWith({ automationId: 'auto-1' }) + }) + + it('manually runs runtime-host automations through that server', async () => { + const automation = makeAutomation() + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ + run: { id: 'run-1', automationId: automation.id } + }) + + await runAutomationNowForTarget(automation) + + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'gpu' }, 'automation.runNow', { id: automation.id }, diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index b5e28fcb6b1..271d02319e8 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -1,21 +1,10 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { Automation, - AutomationCreateInput, AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { GlobalSettings } from '../../../../shared/global-settings-types' - -type RuntimeAutomationCreateInput = Omit< - AutomationCreateInput, - 'projectId' | 'workspaceId' | 'timezone' -> & { - repo?: string - workspace?: string - timezone?: string -} type RuntimeAutomationUpdateInput = Omit & { repo?: string @@ -49,13 +38,6 @@ export function getAutomationTargetFromHostId( : { kind: 'local' } } -export function getAutomationListTarget( - settings: Pick | null | undefined -): AutomationHostTarget { - const environmentId = settings?.activeRuntimeEnvironmentId?.trim() - return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } -} - export function getAutomationOwnerTarget( automation: Pick, sourceTarget?: AutomationHostTarget | null @@ -66,21 +48,6 @@ export function getAutomationOwnerTarget( return getAutomationTargetFromHostId(automation.runContext?.hostId) } -export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { - return getAutomationTargetFromHostId(input.runContext?.hostId) -} - -function toRuntimeAutomationCreateInput( - input: AutomationCreateInput -): RuntimeAutomationCreateInput { - const { projectId, workspaceId, ...rest } = input - return { - ...rest, - repo: projectId, - workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined - } -} - function toRuntimeAutomationUpdateInput( input: AutomationUpdateInput ): RuntimeAutomationUpdateInput { @@ -107,36 +74,27 @@ export async function listAutomationsForTarget( return result.automations } +/** + * One automation's history, never a host's. Usage totals for the list come from + * the authority's own list projection; fetching every run to compute them made + * the page's cost scale with retained history rather than with what is on screen. + */ export async function listAutomationRunsForTarget( target: AutomationHostTarget, - automationId?: string + automationId: string ): Promise { if (target.kind === 'local') { - return await window.api.automations.listRuns(automationId ? { automationId } : undefined) + return await window.api.automations.listRuns({ automationId }) } const result = await callRuntimeRpc<{ runs: AutomationRun[] }>( target, 'automation.runs', - automationId ? { automationId } : {}, + { automationId }, { timeoutMs: 15_000 } ) return result.runs } -export async function createAutomationForTarget(input: AutomationCreateInput): Promise { - const target = getAutomationCreateTarget(input) - if (target.kind === 'local') { - return await window.api.automations.create(input) - } - const result = await callRuntimeRpc<{ automation: Automation }>( - target, - 'automation.create', - toRuntimeAutomationCreateInput(input), - { timeoutMs: 15_000 } - ) - return result.automation -} - export async function updateAutomationForTarget( automation: Automation, updates: AutomationUpdateInput, diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.test.ts b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts new file mode 100644 index 00000000000..cc49fee491c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts @@ -0,0 +1,617 @@ +/** + * The counters are only worth having if they are driven by the real cache and + * scheduler, so these run the instrumentation through both. The design doc's + * release gate is stated over exactly these numbers: calls per authority, one + * legacy call per authority per cycle, and stale responses that never landed. + * + * The distinction these tests exist to hold down is request versus entry. A + * legacy answer is one request serving many hosts, so a counter that increments + * per entry would fail the "one call per authority" gate for a system behaving + * exactly as designed, and would inflate one stale answer into a dozen. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostQuerySupport } from './automation-host-catalog-types' +import { + automationHostDiagnostics, + createAutomationHostDiagnostics, + type AutomationHostDiagnostics +} from './automation-host-diagnostics' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget, + type AutomationHostSchedulerOptions +} from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const callRuntimeRpc = vi.fn() +const getRuntimeEnvironmentStatus = vi.fn() + +// Only the capability-probe suite reaches the real list client; every other test +// injects a transport, so this mock stands in for a paired host that answers. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => getRuntimeEnvironmentStatus(...args), + hasRuntimeRpcErrorCode: () => false +})) + +const RUNTIME_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-1', + pairingRevision: 4 +} +const OTHER_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-2', + pairingRevision: 1 +} +const LEGACY = { querySupport: 'legacy-unscoped' as const } + +function stableAuthority(authority: AutomationAuthorityRef): StableAutomationAuthorityRef { + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: authority.environmentId } +} + +function selfRef(authority: AutomationAuthorityRef): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'self' } } +} + +function sshRef(authority: AutomationAuthorityRef, targetId: string): StableAutomationCatalogRef { + return { + authority: stableAuthority(authority), + selector: { kind: 'ssh', targetId } + } +} + +function orphanRef(authority: AutomationAuthorityRef): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'orphan' } } +} + +function authorityKeyOf(authority: AutomationAuthorityRef): string { + return automationAuthorityCatalogKey(stableAuthority(authority)) +} + +function target( + ref: StableAutomationCatalogRef, + options: { + authority?: AutomationAuthorityRef + querySupport?: AutomationHostQuerySupport + } = {} +): AutomationHostFetchTarget { + const authority = options.authority ?? RUNTIME_AUTHORITY + return { + ref, + authority, + owner: + ref.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: 2 + } + } + : { authority, selector: { kind: 'self' } }, + querySupport: options.querySupport ?? 'scoped' + } +} + +function automation(id: string, overrides: Partial = {}): Automation { + return { + id, + name: id, + projectId: 'repo-1', + executionTargetType: 'local', + ...overrides + } as Automation +} + +function scopedResult(ids: readonly string[]): ScopedAutomationList { + return { + automations: ids.map((id) => automation(id)), + items: ids.map((id) => ({ + automationId: id, + selector: { kind: 'self' as const } + })), + orphanCount: 0, + invalidRows: 0 + } +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const LOCAL_REPO = { repoConnectionId: () => null, projectsAuthoritative: true } + +function harness(): { + cache: AutomationHostCache + diagnostics: AutomationHostDiagnostics + schedule: ( + overrides: Partial + ) => ReturnType +} { + const diagnostics = createAutomationHostDiagnostics() + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + return { + cache, + diagnostics, + schedule: (overrides) => + createAutomationHostScheduler({ + cache, + diagnostics, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + // A retry that never runs: these assert what one cycle cost, and a live + // timer would re-enter the counters after the test read them. + scheduleRetry: () => () => {}, + ...overrides + }) + } +} + +describe('automation host request counters', () => { + it('attributes each scoped call to both its authority and its stable key', async () => { + const { diagnostics, schedule } = harness() + const scheduler = schedule({ + transport: { listScoped: vi.fn(() => Promise.resolve(scopedResult(['a', 'b']))) } + }) + + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')), + target(selfRef(OTHER_AUTHORITY), { authority: OTHER_AUTHORITY }) + ]) + + const snapshot = diagnostics.snapshot() + expect(snapshot.totals.requests).toBe(3) + expect(snapshot.totals.scopedRequests).toBe(3) + expect(snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)].requests).toBe(2) + expect(snapshot.byAuthority[authorityKeyOf(OTHER_AUTHORITY)].requests).toBe(1) + expect(snapshot.byStableKey[hostStableKey(selfRef(RUNTIME_AUTHORITY))].requests).toBe(1) + }) + + // The doc's old-server budget in one number: three entries on one authority + // must still cost one call, and it must be attributable to that authority. + it('counts one legacy call per authority per cycle, not one per entry', async () => { + const { diagnostics, schedule } = harness() + const listLegacy = vi.fn(() => Promise.resolve([automation('self-1')])) + const scheduler = schedule({ transport: { listLegacy } }) + + const refs = [ + selfRef(RUNTIME_AUTHORITY), + sshRef(RUNTIME_AUTHORITY, 'target-1'), + orphanRef(RUNTIME_AUTHORITY) + ] + await scheduler.refresh(refs.map((ref) => target(ref, LEGACY))) + + const snapshot = diagnostics.snapshot() + expect(listLegacy).toHaveBeenCalledTimes(1) + expect(snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)].legacyRequests).toBe(1) + // A legacy answer belongs to no single entry, so no key may claim the call + // even though each key is credited with the rows it took from that answer. + for (const ref of refs) { + expect(snapshot.byStableKey[hostStableKey(ref)]).toMatchObject({ + requests: 0, + legacyRequests: 0, + responses: 0 + }) + } + }) + + it('records a dedupe hit, not a request, when a caller joins one in flight', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = schedule({ transport: { listScoped } }) + + const first = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + await Promise.resolve() + const second = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + gate.resolve(scopedResult(['a'])) + await Promise.all([first, second]) + + const key = hostStableKey(selfRef(RUNTIME_AUTHORITY)) + expect(listScoped).toHaveBeenCalledTimes(1) + expect(diagnostics.snapshot().byStableKey[key]).toMatchObject({ + requests: 1, + dedupeHits: 1 + }) + }) + + // A legacy request is unkeyed, so its dedupe hits must be too — otherwise a + // legacy host reads 0 requests against N hits and no per-key invariant holds. + it('keys a legacy dedupe hit the same way it keys the legacy request', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listLegacy: vi.fn(() => gate.promise) } }) + + const first = scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), LEGACY), + target(sshRef(RUNTIME_AUTHORITY, 'target-1'), LEGACY) + ]) + await Promise.resolve() + const second = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY), LEGACY)]) + gate.resolve([automation('a')]) + await Promise.all([first, second]) + + const snapshot = diagnostics.snapshot() + const authority = snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)] + expect(authority).toMatchObject({ legacyRequests: 1, dedupeHits: 1 }) + expect(snapshot.byStableKey[hostStableKey(selfRef(RUNTIME_AUTHORITY))].dedupeHits).toBe(0) + }) + + it('counts one hit when the same host appears twice in one refresh', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listScoped: vi.fn(() => gate.promise) } }) + + const ref = selfRef(RUNTIME_AUTHORITY) + const first = scheduler.refresh([target(ref)]) + await Promise.resolve() + // plan() collapses the duplicate into one entry; both share one promise. + const second = scheduler.refresh([target(ref), target(ref)]) + gate.resolve(scopedResult(['a'])) + await Promise.all([first, second]) + + expect(diagnostics.snapshot().byStableKey[hostStableKey(ref)].dedupeHits).toBe(1) + }) + + it('never records a request for a host it refuses to call', async () => { + const { diagnostics, schedule } = harness() + const listScoped = vi.fn() + const scheduler = schedule({ transport: { listScoped } }) + + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'incompatible' })]) + + expect(listScoped).not.toHaveBeenCalled() + // A local refusal is not wire traffic, and its failure is not a discard. + expect(diagnostics.snapshot().totals).toMatchObject({ + requests: 0, + failures: 0, + discardedFailures: 0, + discardedEntries: 0 + }) + }) + + // The pool drops queued work the catalog has made obsolete, so counting at + // submission would bill a host switch for calls that were never sent. + it('does not count a queued request that was cancelled before it was sent', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = schedule({ concurrency: 1, transport: { listScoped } }) + + const pending = scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')) + ]) + await Promise.resolve() + scheduler.cancelQueued() + gate.resolve(scopedResult(['a'])) + await pending + + expect(listScoped).toHaveBeenCalledTimes(1) + expect(diagnostics.snapshot().totals.requests).toBe(1) + }) + + // A cancelled job leaves nothing to join, so the refresh that follows is a new + // request — counting it as a dedupe hit would credit a call never sent. + it('does not count a dedupe hit against a cancelled queued request', async () => { + const { diagnostics, schedule } = harness() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = schedule({ concurrency: 1, transport: { listScoped } }) + + const cancelled = sshRef(RUNTIME_AUTHORITY, 'target-1') + const pending = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), target(cancelled)]) + await Promise.resolve() + scheduler.cancelQueued() + gate.resolve(scopedResult(['a'])) + await pending + + await scheduler.refresh([target(cancelled)]) + expect(diagnostics.snapshot().byStableKey[hostStableKey(cancelled)]).toMatchObject({ + dedupeHits: 0, + requests: 1 + }) + }) +}) + +describe('unpooled capability probes', () => { + beforeEach(() => { + callRuntimeRpc.mockReset() + getRuntimeEnvironmentStatus.mockReset() + automationHostDiagnostics.reset() + }) + + // The probe rides outside the four-slot pool and always re-fetches, so an + // instrument blind to it would report half the relay traffic a refresh costs. + it('counts the status.get probe every runtime-scoped list rides on', async () => { + const { cache } = harness() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + callRuntimeRpc.mockImplementation((_target: unknown, _method: unknown, params: unknown) => { + const selector = (params as { selector: AutomationListScopeSelector }).selector + return Promise.resolve({ + automations: [{ id: 'a' }], + items: [{ automationId: 'a', selector }], + orphanCount: 0 + }) + }) + // No injected transport: this is the one suite that drives the real client. + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true + }) + + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')) + ]) + + const authority = + automationHostDiagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)] + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + // Two pooled list calls, and two round trips the pool never sees. + expect(authority).toMatchObject({ requests: 2, capabilityProbes: 2 }) + }) +}) + +describe('automation host stale-response counters', () => { + it('counts a commit the fence threw away, and still counts the rows it cost', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listScoped: vi.fn(() => gate.promise) } }) + + const ref = selfRef(RUNTIME_AUTHORITY) + const pending = scheduler.refresh([target(ref)]) + await Promise.resolve() + // The user left this host while its answer was still on the wire. + cache.invalidate(ref) + gate.resolve(scopedResult(['a', 'b'])) + await pending + + const counters = diagnostics.snapshot().byStableKey[hostStableKey(ref)] + expect(cache.get(ref)?.data).toEqual([]) + expect(counters).toMatchObject({ discardedCommits: 1, discardedEntries: 1 }) + // Recorded even though nothing landed: the wire cost was paid regardless. + expect(counters.rows).toBe(2) + }) + + it('separates a discarded failure from a discarded commit', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listScoped: vi.fn(() => gate.promise) } }) + + const ref = selfRef(RUNTIME_AUTHORITY) + const pending = scheduler.refresh([target(ref)]) + await Promise.resolve() + cache.invalidate(ref) + gate.reject(new Error('host went away')) + await pending + + expect(diagnostics.snapshot().byStableKey[hostStableKey(ref)]).toMatchObject({ + discardedCommits: 0, + discardedFailures: 1, + // The attempt still happened and still took time, fence or no fence. + failures: 1 + }) + }) + + // One stale legacy answer is one discarded request over many discarded + // entries. Counting it per entry would report twelve stale responses for a + // twelve-host authority that received exactly one. + it('counts a stale legacy answer once as a request and once per entry', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listLegacy: vi.fn(() => gate.promise) } }) + + const refs = [ + selfRef(RUNTIME_AUTHORITY), + sshRef(RUNTIME_AUTHORITY, 'target-1'), + orphanRef(RUNTIME_AUTHORITY) + ] + const pending = scheduler.refresh(refs.map((ref) => target(ref, LEGACY))) + await Promise.resolve() + // The whole authority re-paired while its one answer was in flight. + cache.invalidateAuthority(stableAuthority(RUNTIME_AUTHORITY)) + gate.resolve([automation('a')]) + await pending + + expect(diagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)]).toMatchObject({ + responses: 1, + discardedCommits: 1, + discardedEntries: 3 + }) + }) + + // The gate `discardedCommits === 0` has to stay assertable for a system that + // is behaving: an answer that landed somewhere was not thrown away. + it('does not call a legacy answer discarded when one of its entries landed', async () => { + const { cache, diagnostics, schedule } = harness() + const gate = deferred() + const scheduler = schedule({ transport: { listLegacy: vi.fn(() => gate.promise) } }) + + const stale = sshRef(RUNTIME_AUTHORITY, 'target-1') + const pending = scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), LEGACY), + target(stale, LEGACY) + ]) + await Promise.resolve() + cache.invalidate(stale) + gate.resolve([automation('a')]) + await pending + + expect(diagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)]).toMatchObject({ + discardedCommits: 0, + discardedEntries: 1 + }) + }) +}) + +describe('automation host response measurements', () => { + it('records row counts and refresh duration per response', async () => { + const { diagnostics, schedule } = harness() + const ticks = [100, 145, 200, 320] + let tick = 0 + const scheduler = schedule({ + elapsed: () => ticks[Math.min(tick++, ticks.length - 1)], + concurrency: 1, + transport: { listScoped: vi.fn(() => Promise.resolve(scopedResult(['a', 'b', 'c']))) } + }) + + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY)), + target(sshRef(RUNTIME_AUTHORITY, 'target-1')) + ]) + + const totals = diagnostics.snapshot().totals + expect(totals.responses).toBe(2) + expect(totals.rows).toBe(6) + expect(totals.durationMsTotal).toBe(45 + 120) + expect(totals.durationMsMax).toBe(120) + }) + + // A host that times out is the slowest thing a refresh does. Recording + // duration only on success hides exactly the requests worth finding. + it('records the duration of a request that failed', async () => { + const { diagnostics, schedule } = harness() + const ticks = [100, 175] + let tick = 0 + const scheduler = schedule({ + elapsed: () => ticks[Math.min(tick++, ticks.length - 1)], + concurrency: 1, + transport: { listScoped: vi.fn(() => Promise.reject(new Error('timed out'))) } + }) + + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + + expect(diagnostics.snapshot().totals).toMatchObject({ + responses: 0, + failures: 1, + durationMsTotal: 75, + durationMsMax: 75 + }) + }) + + // Doc 416 asks for row counts per host, and the legacy fan-out is where they + // would otherwise be lost — precisely where the payload is largest. + it('attributes legacy rows to each host, not only to the authority', async () => { + const { diagnostics, schedule } = harness() + const scheduler = schedule({ + transport: { + listLegacy: vi.fn(() => + Promise.resolve([automation('a'), automation('b'), automation('c')]) + ) + } + }) + + const self = selfRef(RUNTIME_AUTHORITY) + const ssh = sshRef(RUNTIME_AUTHORITY, 'target-1') + await scheduler.refresh([target(self, LEGACY), target(ssh, LEGACY)]) + + const snapshot = diagnostics.snapshot() + // The authority's number is what the response carried... + expect(snapshot.byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)].rows).toBe(3) + // ...and each key's is what that host took from it, request or no request. + expect(snapshot.byStableKey[hostStableKey(self)]).toMatchObject({ rows: 3, requests: 0 }) + expect(snapshot.byStableKey[hostStableKey(ssh)].rows).toBe(0) + }) + + // Sizing re-serializes a payload the transport already decoded, so it stays + // off unless someone is profiling. The flag keeps an unmeasured zero from + // reading as a real one. + it('leaves response size unmeasured until measurement is turned on', async () => { + const { diagnostics, schedule } = harness() + const scheduler = schedule({ + transport: { listScoped: vi.fn(() => Promise.resolve(scopedResult(['a']))) } + }) + + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + expect(diagnostics.snapshot()).toMatchObject({ + serializedCharsMeasured: false, + totals: { approxSerializedChars: 0 } + }) + + diagnostics.setMeasureSerializedChars(true) + await scheduler.refresh([target(sshRef(RUNTIME_AUTHORITY, 'target-1'))]) + const measured = diagnostics.snapshot() + expect(measured.serializedCharsMeasured).toBe(true) + expect(measured.totals.approxSerializedChars).toBeGreaterThan(0) + }) +}) + +describe('automation host diagnostics bookkeeping', () => { + it('bounds tracked keys so a long session cannot grow without limit', () => { + const diagnostics = createAutomationHostDiagnostics(2) + for (const key of ['k1', 'k2', 'k3']) { + diagnostics.recordRequest({ + authorityKey: 'a', + stableKey: key, + transport: 'scoped' + }) + } + const snapshot = diagnostics.snapshot() + expect(Object.keys(snapshot.byStableKey)).toEqual(['k2', 'k3']) + // Evicting a key must not rewrite history: the totals still saw three calls. + // This is also why sum(byStableKey) is a lower bound on totals, not an equality. + expect(snapshot.totals.requests).toBe(3) + }) + + it('clears every bucket on reset so a profiling run starts from zero', () => { + const diagnostics = createAutomationHostDiagnostics() + diagnostics.recordRequest({ + authorityKey: 'a', + stableKey: 'k1', + transport: 'legacy' + }) + diagnostics.recordDedupeHit({ authorityKey: 'a', stableKey: 'k1' }) + diagnostics.reset() + expect(diagnostics.snapshot()).toMatchObject({ + totals: { requests: 0, dedupeHits: 0, legacyRequests: 0 }, + byAuthority: {}, + byStableKey: {} + }) + }) + + it('never copies an automation, a prompt, or a name into a counter', () => { + const diagnostics = createAutomationHostDiagnostics() + diagnostics.recordResponse({ + authorityKey: 'a', + stableKey: 'k1', + rows: 3, + durationMs: 12, + approxSerializedChars: 900 + }) + expect(JSON.stringify(diagnostics.snapshot())).not.toMatch(/prompt|automation-|secret/i) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.ts b/src/renderer/src/components/automations/automation-host-diagnostics.ts new file mode 100644 index 00000000000..7428cf7d21a --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-diagnostics.ts @@ -0,0 +1,314 @@ +/** + * Counters behind the design doc's request-count and stale-response release + * gates: how many authority calls the cache actually sent, how many callers + * joined one already in flight, how many answers the fence threw away, and how + * big/slow each answer was. + * + * Nothing here holds an automation, a prompt, or run output — only counts, + * durations, and the stable/authority keys the gate is stated in terms of. + * + * Two accounting rules run through the whole file, because the gates are stated + * per *request* while the cache settles per *entry*: + * - A legacy unscoped call is one request for its whole authority. It is + * counted against the authority and against no stable key, so "one legacy + * call per authority per cycle" stays a number you can assert on. + * - Anything counted per entry (`discardedEntries`, per-key `rows`) says so at + * the field, and must never be divided by or compared against `responses`. + */ + +export type AutomationHostRequestTransport = 'scoped' | 'legacy' + +export type AutomationHostKeyCounters = { + /** Pooled authority calls sent. Legacy calls land on the authority only. */ + requests: number + scopedRequests: number + legacyRequests: number + /** + * Unpooled `status.get` capability probes, which ride outside the four-slot + * pool and always re-fetch. Relay traffic is `requests + capabilityProbes`; + * the in-flight ceiling is stated over `requests` alone. + */ + capabilityProbes: number + /** Callers that joined a request already in flight, keyed like `requests`. */ + dedupeHits: number + /** Requests whose answer landed nowhere because the fence rejected every entry. */ + discardedCommits: number + discardedFailures: number + /** + * Entries the fence rejected. One legacy answer can reject many at once, so + * this is per entry and is not comparable with `responses` or `requests`. + */ + discardedEntries: number + responses: number + /** Requests that threw. `durationMsTotal` spans these too. */ + failures: number + /** + * On `totals` and `byAuthority`, rows the response carried. On `byStableKey`, + * rows attributed to that entry — a legacy answer is counted once against its + * authority and fanned out to keys for row attribution only, so a key can + * carry rows while its `requests` is 0. + */ + rows: number + /** See `serializedCharsMeasured`; 0 also means "not measured". */ + approxSerializedChars: number + /** Spans every completed request; the denominator is `responses + failures`. */ + durationMsTotal: number + durationMsMax: number +} + +export type AutomationHostDiagnosticsSnapshot = { + totals: AutomationHostKeyCounters + /** + * Bucket totals can trail `totals` after eviction, so `sum(byStableKey)` is a + * lower bound on `totals`, never an equality. + */ + byAuthority: Record + byStableKey: Record + /** + * Sizing requires re-serializing a response the transport already decoded, + * which is the one measurement here expensive enough to disturb the 50 ms + * budget it exists to verify. False means unmeasured, not zero. + */ + serializedCharsMeasured: boolean +} + +export type AutomationHostRequestRecord = { + authorityKey: string + /** Null for a legacy authority call: it answers for every key at once. */ + stableKey: string | null + transport: AutomationHostRequestTransport +} + +export type AutomationHostResponseRecord = { + authorityKey: string + /** Null for a legacy authority answer, which belongs to no single key. */ + stableKey: string | null + rows: number + durationMs: number + /** UTF-16 code units of the re-serialized payload, when measurement is on. */ + approxSerializedChars?: number +} + +export type AutomationHostFailureRecord = { + authorityKey: string + stableKey: string | null + durationMs: number +} + +export type AutomationHostDiscardRecord = { + authorityKey: string + stableKey: string | null + outcome: 'commit' | 'failure' +} + +export type AutomationHostDiagnostics = { + recordRequest: (record: AutomationHostRequestRecord) => void + /** Keyed by authority alone: the probe answers for the host, not for one entry. */ + recordCapabilityProbe: (record: { authorityKey: string }) => void + recordDedupeHit: (record: { authorityKey: string; stableKey: string | null }) => void + recordResponse: (record: AutomationHostResponseRecord) => void + recordFailedRequest: (record: AutomationHostFailureRecord) => void + /** Rows one entry took from a legacy answer; touches that key's bucket only. */ + recordEntryRows: (record: { stableKey: string; rows: number }) => void + recordDiscardedRequest: (record: AutomationHostDiscardRecord) => void + recordDiscardedEntry: (record: { authorityKey: string; stableKey: string }) => void + /** True only while measuring; callers skip the serialization cost otherwise. */ + measuringSerializedChars: () => boolean + setMeasureSerializedChars: (enabled: boolean) => void + snapshot: () => AutomationHostDiagnosticsSnapshot + reset: () => void +} + +// Bounds memory when a session churns through hosts: the gate is stated over a +// 50-host fixture, so a cap well above that loses nothing it needs to prove. +const MAX_TRACKED_KEYS = 512 + +function emptyCounters(): AutomationHostKeyCounters { + return { + requests: 0, + scopedRequests: 0, + legacyRequests: 0, + capabilityProbes: 0, + dedupeHits: 0, + discardedCommits: 0, + discardedFailures: 0, + discardedEntries: 0, + responses: 0, + failures: 0, + rows: 0, + approxSerializedChars: 0, + durationMsTotal: 0, + durationMsMax: 0 + } +} + +export function createAutomationHostDiagnostics( + maxTrackedKeys = MAX_TRACKED_KEYS +): AutomationHostDiagnostics { + const totals = emptyCounters() + const byAuthority = new Map() + const byStableKey = new Map() + let measureSerializedChars = false + + const bucket = ( + buckets: Map, + key: string + ): AutomationHostKeyCounters => { + const existing = buckets.get(key) + if (existing) { + return existing + } + // Eviction is first-seen order, not least-recently-used: keeping it that way + // costs no re-insert on the recording path, and at 512 keys against a 50-host + // gate nothing under test is ever reached. + if (buckets.size >= maxTrackedKeys) { + const oldest = buckets.keys().next() + if (!oldest.done) { + buckets.delete(oldest.value) + } + } + const created = emptyCounters() + buckets.set(key, created) + return created + } + + const targets = ( + authorityKey: string, + stableKey: string | null + ): readonly AutomationHostKeyCounters[] => + stableKey === null + ? [totals, bucket(byAuthority, authorityKey)] + : [totals, bucket(byAuthority, authorityKey), bucket(byStableKey, stableKey)] + + const toRecord = ( + buckets: Map + ): Record => + Object.fromEntries([...buckets].map(([key, counters]) => [key, { ...counters }])) + + const addDuration = (counters: AutomationHostKeyCounters, durationMs: number): void => { + counters.durationMsTotal += durationMs + counters.durationMsMax = Math.max(counters.durationMsMax, durationMs) + } + + return { + recordRequest: ({ authorityKey, stableKey, transport }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.requests += 1 + if (transport === 'legacy') { + counters.legacyRequests += 1 + } else { + counters.scopedRequests += 1 + } + } + }, + recordCapabilityProbe: ({ authorityKey }) => { + for (const counters of targets(authorityKey, null)) { + counters.capabilityProbes += 1 + } + }, + recordDedupeHit: ({ authorityKey, stableKey }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.dedupeHits += 1 + } + }, + recordResponse: ({ authorityKey, stableKey, rows, durationMs, approxSerializedChars }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.responses += 1 + counters.rows += rows + counters.approxSerializedChars += approxSerializedChars ?? 0 + addDuration(counters, durationMs) + } + }, + recordFailedRequest: ({ authorityKey, stableKey, durationMs }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.failures += 1 + addDuration(counters, durationMs) + } + }, + recordEntryRows: ({ stableKey, rows }) => { + bucket(byStableKey, stableKey).rows += rows + }, + recordDiscardedRequest: ({ authorityKey, stableKey, outcome }) => { + for (const counters of targets(authorityKey, stableKey)) { + if (outcome === 'commit') { + counters.discardedCommits += 1 + } else { + counters.discardedFailures += 1 + } + } + }, + recordDiscardedEntry: ({ authorityKey, stableKey }) => { + for (const counters of targets(authorityKey, stableKey)) { + counters.discardedEntries += 1 + } + }, + measuringSerializedChars: () => measureSerializedChars, + setMeasureSerializedChars: (enabled) => { + measureSerializedChars = enabled + }, + snapshot: () => ({ + totals: { ...totals }, + byAuthority: toRecord(byAuthority), + byStableKey: toRecord(byStableKey), + serializedCharsMeasured: measureSerializedChars + }), + reset: () => { + Object.assign(totals, emptyCounters()) + byAuthority.clear() + byStableKey.clear() + } + } +} + +/** Shared instance the scheduler and list client write to when nothing is injected. */ +export const automationHostDiagnostics = createAutomationHostDiagnostics() + +/** + * Payload size as UTF-16 code units of a re-serialization — an approximation of + * what crossed the wire, not a byte count, which is why nothing here is named + * `bytes`. Guarded by the caller's `measuringSerializedChars()` check, and + * undefined when the payload will not serialize: an unmeasurable response must + * not read as a zero-sized one. + */ +export function measureAutomationHostResponseChars(payload: unknown): number | undefined { + try { + return JSON.stringify(payload)?.length + } catch { + return undefined + } +} + +export type AutomationHostDiagnosticsBridge = { + report: () => AutomationHostDiagnosticsSnapshot + measureSerializedChars: (enabled?: boolean) => string + reset: () => string +} + +type AutomationHostDiagnosticsWindow = Window & { + __orcaAutomationHostDiagnostic?: AutomationHostDiagnosticsBridge +} + +export function installAutomationHostDiagnostic(): void { + if (typeof window === 'undefined') { + return + } + const target = window as AutomationHostDiagnosticsWindow + if (target.__orcaAutomationHostDiagnostic) { + return + } + target.__orcaAutomationHostDiagnostic = { + report: () => { + const snapshot = automationHostDiagnostics.snapshot() + console.log('[orca] automation host cache diagnostics', snapshot) + return snapshot + }, + measureSerializedChars: (enabled = true) => { + automationHostDiagnostics.setMeasureSerializedChars(enabled) + return `Automation host response sizing ${enabled ? 'on' : 'off'}.` + }, + reset: () => { + automationHostDiagnostics.reset() + return 'Automation host cache diagnostics reset.' + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-fetch-target.ts b/src/renderer/src/components/automations/automation-host-fetch-target.ts new file mode 100644 index 00000000000..a41f404793c --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-fetch-target.ts @@ -0,0 +1,42 @@ +/** + * Builds the fetch target a manual retry needs. + * + * The controller derives these itself for scheduled work; a user-initiated + * Retry has to name one entry, and it must carry the same incarnation the + * scheduler would have used or the response cannot commit. + */ + +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostFetchTarget } from './automation-host-scheduler' + +export function automationHostAuthorityRef( + entry: AutomationHostCatalogEntry, + pairingRevision: (environmentId: string) => number +): AutomationAuthorityRef { + if (entry.owner) { + return entry.owner.authority + } + const authority = entry.stableRef.authority + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { + kind: 'runtime', + environmentId: authority.environmentId, + pairingRevision: pairingRevision(authority.environmentId) + } +} + +export function automationHostFetchTarget( + entry: AutomationHostCatalogEntry, + pairingRevision: (environmentId: string) => number +): AutomationHostFetchTarget { + return { + ref: entry.stableRef, + authority: automationHostAuthorityRef(entry, pairingRevision), + owner: entry.owner, + querySupport: entry.querySupport, + // A retry is always what the user is waiting on, so it jumps the queue. + priority: true + } +} diff --git a/src/renderer/src/components/automations/automation-host-filter-resolution.test.ts b/src/renderer/src/components/automations/automation-host-filter-resolution.test.ts new file mode 100644 index 00000000000..59ba583fb03 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-filter-resolution.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import type { + AutomationCatalogSshMirrorInput, + AutomationHostCatalogInput, + AutomationRuntimeAuthorityInput +} from './automation-host-catalog-types' +import { resolveAutomationHostFilter } from './automation-host-filter-resolution' + +function mirror( + overrides: Partial = {} +): AutomationCatalogSshMirrorInput { + return { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map(), + ...overrides + } +} + +function runtime( + overrides: Partial = {} +): AutomationRuntimeAuthorityInput { + return { + environmentId: 'env-1', + label: 'Build server', + pairingRevision: 3, + authorityHealth: 'fresh', + querySupport: 'scoped', + ssh: mirror(), + ...overrides + } +} + +function catalogOf(overrides: Partial = {}) { + return buildAutomationHostCatalog({ + desktop: { label: 'Local Mac', ssh: mirror() }, + runtimes: [], + runtimeCatalogSettled: true, + ...overrides + }) +} + +function filterFor(host: StableAutomationCatalogRef): AutomationHostFilter { + return { kind: 'host', host } +} + +const desktopSsh = (targetId: string): StableAutomationCatalogRef => ({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId } +}) +const runtimeSelf: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'self' } +} +const runtimeSsh: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'nested' } +} +const desktopOrphan: StableAutomationCatalogRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'orphan' } +} + +describe('resolveAutomationHostFilter', () => { + it('passes All hosts through untouched', () => { + expect(resolveAutomationHostFilter({ filter: { kind: 'all' }, catalog: catalogOf() })).toEqual({ + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false + }) + }) + + it('resolves Desktop + Self immediately', () => { + const filter = filterFor({ authority: { kind: 'desktop' }, selector: { kind: 'self' } }) + const resolution = resolveAutomationHostFilter({ filter, catalog: catalogOf() }) + expect(resolution.status).toBe('ready') + expect(resolution.effective).toBe(filter) + expect(resolution.entry?.label).toBe('Local Mac') + }) + + it('retains a desktop SSH selection until the target list hydrates', () => { + const filter = filterFor(desktopSsh('pending')) + const resolution = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ + desktop: { label: 'Local Mac', ssh: mirror({ targetsHydrated: false }) } + }) + }) + expect(resolution).toMatchObject({ status: 'loading', announceFallback: false }) + expect(resolution.effective).toBe(filter) + }) + + it('falls back and announces once desktop SSH absence is authoritative', () => { + const resolution = resolveAutomationHostFilter({ + filter: filterFor(desktopSsh('gone')), + catalog: catalogOf() + }) + expect(resolution).toEqual({ + effective: { kind: 'all' }, + entry: null, + status: 'removed', + announceFallback: true + }) + }) + + it('keeps a removed host as a ghost while automations still reference it', () => { + const host = desktopSsh('gone') + const key = hostStableKey(host) + const resolution = resolveAutomationHostFilter({ + filter: filterFor(host), + catalog: catalogOf({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + }, + referencedStableKeys: [key] + }), + referencedStableKeys: new Set([key]) + }) + expect(resolution.status).toBe('ghost') + expect(resolution.announceFallback).toBe(false) + expect(resolution.entry?.label).toBe('Old box') + }) + + it('drops a tombstoned host that nothing else references', () => { + const host = desktopSsh('gone') + const resolution = resolveAutomationHostFilter({ + filter: filterFor(host), + catalog: catalogOf({ + desktop: { + label: 'Local Mac', + ssh: mirror({ removedTargetLabels: new Map([['gone', 'Old box']]) }) + } + }) + }) + expect(resolution.status).toBe('removed') + expect(resolution.announceFallback).toBe(true) + }) + + it('retains a Runtime Self selection until the saved runtime catalog settles', () => { + const filter = filterFor(runtimeSelf) + expect( + resolveAutomationHostFilter({ filter, catalog: catalogOf({ runtimeCatalogSettled: false }) }) + .status + ).toBe('loading') + expect(resolveAutomationHostFilter({ filter, catalog: catalogOf() })).toMatchObject({ + status: 'removed', + announceFallback: true + }) + }) + + it('retains a Runtime SSH selection until that runtime bucket hydrates', () => { + const filter = filterFor(runtimeSsh) + expect( + resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ ssh: mirror({ targetsHydrated: false }) })] }) + }).status + ).toBe('loading') + expect( + resolveAutomationHostFilter({ filter, catalog: catalogOf({ runtimes: [runtime()] }) }) + ).toMatchObject({ status: 'removed', announceFallback: true }) + }) + + it('accepts a removal tombstone as positive evidence for a nested target', () => { + const resolution = resolveAutomationHostFilter({ + filter: filterFor(runtimeSsh), + catalog: catalogOf({ + runtimes: [ + runtime({ + ssh: mirror({ + targetsHydrated: false, + removedTargetLabels: new Map([['nested', 'Nested box']]) + }) + }) + ] + }) + }) + expect(resolution.status).toBe('removed') + expect(resolution.announceFallback).toBe(true) + }) + + it('never claims removal while the owning authority is offline', () => { + const offline = catalogOf({ + runtimes: [ + runtime({ + authorityHealth: 'unavailable', + ssh: mirror({ removedTargetLabels: new Map([['nested', 'Nested box']]) }) + }) + ] + }) + expect( + resolveAutomationHostFilter({ filter: filterFor(runtimeSsh), catalog: offline }) + ).toMatchObject({ status: 'unavailable', announceFallback: false }) + expect( + resolveAutomationHostFilter({ + filter: filterFor({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'never-seen' } + }), + catalog: offline + }) + ).toMatchObject({ status: 'unavailable', announceFallback: false }) + }) + + it('settles an orphan selection only on an authoritative orphan count', () => { + const filter = filterFor(desktopOrphan) + expect(resolveAutomationHostFilter({ filter, catalog: catalogOf() }).status).toBe('loading') + expect( + resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 2 } }) + }).status + ).toBe('ready') + expect( + resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ desktop: { label: 'Local Mac', ssh: mirror(), orphanCount: 0 } }) + }) + ).toMatchObject({ status: 'removed', announceFallback: true }) + }) + + it('retains an orphan selection as unavailable while its authority is offline', () => { + const resolution = resolveAutomationHostFilter({ + filter: filterFor({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'orphan' } + }), + catalog: catalogOf({ runtimes: [runtime({ authorityHealth: 'unavailable' })] }) + }) + expect(resolution).toMatchObject({ status: 'unavailable', announceFallback: false }) + }) + + it('retains the display selection through a same-id re-pair', () => { + const filter = filterFor(runtimeSelf) + const before = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ pairingRevision: 3 })] }) + }) + const after = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ pairingRevision: 4 })] }) + }) + expect(after.status).toBe('ready') + expect(after.entry?.stableKey).toBe(before.entry?.stableKey) + // The display slot survives; the captured incarnation does not. + expect(after.entry?.owner).not.toEqual(before.entry?.owner) + }) + + it('retains the selection through a rename', () => { + const filter = filterFor(runtimeSelf) + const resolution = resolveAutomationHostFilter({ + filter, + catalog: catalogOf({ runtimes: [runtime({ label: 'Renamed server' })] }) + }) + expect(resolution.status).toBe('ready') + expect(resolution.entry?.label).toBe('Renamed server') + expect(resolution.entry?.stableKey).toBe(hostStableKey(runtimeSelf)) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-filter-resolution.ts b/src/renderer/src/components/automations/automation-host-filter-resolution.ts new file mode 100644 index 00000000000..15350cb00b7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-filter-resolution.ts @@ -0,0 +1,141 @@ +import { + ALL_AUTOMATION_HOSTS_FILTER, + automationHostFilterStableKey, + type AutomationHostFilter +} from '../../../../shared/automation-host-filter' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationCatalogHydrationEvidence, + type AutomationHostCatalog, + type AutomationHostCatalogEntry +} from './automation-host-catalog-types' + +/** + * Restores a persisted host selection against the live catalog. + * + * The rule that makes this non-trivial: missing catalog data is never evidence + * of removal (required invariant 4). Until the relevant catalog settles the + * selection is retained and reported as `loading`; only positive evidence — + * a hydrated target list, a settled runtime catalog, or a tombstone — may drop it. + */ + +export type AutomationHostFilterStatus = + | 'all' + /** The selected host is present and authoritative. */ + | 'ready' + /** Relevant state is unhydrated. Retain the selection and show `Loading host…`. */ + | 'loading' + /** The owning authority is offline, so absence proves nothing. Retain. */ + | 'unavailable' + /** Positively removed but still referenced by automations or cached rows. Retain as a ghost. */ + | 'ghost' + /** Positively removed and unreferenced: fall back to All hosts and announce it. */ + | 'removed' + +export type AutomationHostFilterResolution = { + /** What the list should actually render and query. */ + effective: AutomationHostFilter + entry: AutomationHostCatalogEntry | null + status: AutomationHostFilterStatus + /** True only when a saved selection was dropped; the change must be announced. */ + announceFallback: boolean +} + +export type AutomationHostFilterResolutionInput = { + filter: AutomationHostFilter + catalog: AutomationHostCatalog + /** Stable keys still referenced by a stored automation or cached row, tombstones aside. */ + referencedStableKeys?: ReadonlySet +} + +const ALL_RESOLUTION: AutomationHostFilterResolution = { + effective: ALL_AUTOMATION_HOSTS_FILTER, + entry: null, + status: 'all', + announceFallback: false +} + +function retain( + filter: AutomationHostFilter, + entry: AutomationHostCatalogEntry | null, + status: AutomationHostFilterStatus +): AutomationHostFilterResolution { + return { effective: filter, entry, status, announceFallback: false } +} + +function fallBackToAllHosts(): AutomationHostFilterResolution { + return { + effective: ALL_AUTOMATION_HOSTS_FILTER, + entry: null, + status: 'removed', + announceFallback: true + } +} + +function resolvePresentEntry( + filter: AutomationHostFilter, + entry: AutomationHostCatalogEntry, + referenced: boolean +): AutomationHostFilterResolution { + if (entry.authorityHealth === 'unavailable') { + // Why: an offline authority can neither confirm removal nor prove an orphan was repaired. + return retain(filter, entry, 'unavailable') + } + if (entry.catalogState === 'unhydrated') { + return retain(filter, entry, 'loading') + } + if (entry.catalogState === 'removed') { + return referenced ? retain(filter, entry, 'ghost') : fallBackToAllHosts() + } + return retain(filter, entry, 'ready') +} + +/** Positive-evidence gate for a selection that has no catalog entry at all. */ +function resolveAbsentEntry( + filter: AutomationHostFilter, + host: StableAutomationCatalogRef, + hydration: AutomationCatalogHydrationEvidence +): AutomationHostFilterResolution { + const authorityKey = automationAuthorityCatalogKey(host.authority) + if (hydration.unavailableAuthorityKeys.has(authorityKey)) { + return retain(filter, null, 'unavailable') + } + if (host.authority.kind === 'runtime') { + const environmentId = host.authority.environmentId + if (!hydration.savedRuntimeEnvironmentIds.has(environmentId)) { + // The whole authority is gone — only a settled saved-runtime catalog proves that. + return hydration.runtimeCatalogSettled + ? fallBackToAllHosts() + : retain(filter, null, 'loading') + } + } + if (host.selector.kind === 'self') { + // Desktop Self and every saved runtime's Self are always projected, so an + // absent one means the saved catalog has not produced it yet. + return retain(filter, null, 'loading') + } + if (host.selector.kind === 'orphan') { + return hydration.orphanSettledAuthorityKeys.has(authorityKey) + ? fallBackToAllHosts() + : retain(filter, null, 'loading') + } + const sshHydrated = + host.authority.kind === 'desktop' + ? hydration.desktopSshHydrated + : (hydration.runtimeSshHydratedByEnvironmentId.get(host.authority.environmentId) ?? false) + return sshHydrated ? fallBackToAllHosts() : retain(filter, null, 'loading') +} + +export function resolveAutomationHostFilter( + input: AutomationHostFilterResolutionInput +): AutomationHostFilterResolution { + const stableKey = automationHostFilterStableKey(input.filter) + if (input.filter.kind === 'all' || stableKey === null) { + return ALL_RESOLUTION + } + const entry = input.catalog.byStableKey.get(stableKey) + return entry + ? resolvePresentEntry(input.filter, entry, input.referencedStableKeys?.has(stableKey) ?? false) + : resolveAbsentEntry(input.filter, input.filter.host, input.catalog.hydration) +} diff --git a/src/renderer/src/components/automations/automation-host-health.test.ts b/src/renderer/src/components/automations/automation-host-health.test.ts new file mode 100644 index 00000000000..571a36b544e --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-health.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + resolveAutomationAuthorityHealth, + resolveSelfExecutionHealth, + resolveSshExecutionHealth +} from './automation-host-health' + +const reachable = { reachable: true, compatible: true, hasData: true } as const + +describe('resolveAutomationAuthorityHealth', () => { + it('reports incompatibility ahead of every other signal', () => { + expect( + resolveAutomationAuthorityHealth({ + reachable: false, + compatible: false, + queryState: 'error', + hasData: false + }) + ).toBe('incompatible') + }) + + it('separates first load from a background refresh', () => { + expect( + resolveAutomationAuthorityHealth({ ...reachable, hasData: false, queryState: 'loading' }) + ).toBe('loading') + expect(resolveAutomationAuthorityHealth({ ...reachable, queryState: 'loading' })).toBe( + 'refreshing' + ) + expect(resolveAutomationAuthorityHealth({ ...reachable, queryState: 'idle' })).toBe('fresh') + expect(resolveAutomationAuthorityHealth({ ...reachable, queryState: 'error' })).toBe( + 'stale-error' + ) + }) + + it('reports an unreachable authority as unavailable', () => { + expect( + resolveAutomationAuthorityHealth({ ...reachable, reachable: false, queryState: 'idle' }) + ).toBe('unavailable') + }) +}) + +describe('execution health', () => { + it('never borrows a status for unverified or removed registrations', () => { + expect(resolveSshExecutionHealth('removed', 'connected', 'disconnected')).toBe('unavailable') + expect(resolveSshExecutionHealth('unhydrated', 'connected', 'disconnected')).toBe('unknown') + }) + + it('maps every SSH status onto a distinct execution state', () => { + expect(resolveSshExecutionHealth('authoritative', 'connected', undefined)).toBe('connected') + expect(resolveSshExecutionHealth('authoritative', 'reconnecting', undefined)).toBe('connecting') + expect(resolveSshExecutionHealth('authoritative', 'deploying-relay', undefined)).toBe( + 'connecting' + ) + expect(resolveSshExecutionHealth('authoritative', 'auth-failed', undefined)).toBe( + 'disconnected' + ) + expect(resolveSshExecutionHealth('authoritative', undefined, undefined)).toBe('unknown') + expect(resolveSshExecutionHealth('authoritative', undefined, 'disconnected')).toBe( + 'disconnected' + ) + }) + + it('treats an offline authority as disconnected, not gone', () => { + expect(resolveSelfExecutionHealth('unavailable')).toBe('disconnected') + expect(resolveSelfExecutionHealth('stale-error')).toBe('connected') + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-health.ts b/src/renderer/src/components/automations/automation-host-health.ts new file mode 100644 index 00000000000..df1a461cfc0 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-health.ts @@ -0,0 +1,90 @@ +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import type { + AutomationAuthorityHealth, + AutomationExecutionHealth, + AutomationHostCatalogState +} from './automation-host-catalog-types' + +/** Cache-side view of an authority's list query, supplied by the caller. */ +export type AutomationAuthorityQueryState = 'idle' | 'loading' | 'refreshing' | 'error' + +export type AutomationAuthorityHealthInput = { + /** The authority answered recently. Desktop is always reachable. */ + reachable: boolean + /** False once the authority is known to lack the required list capability. */ + compatible: boolean + queryState: AutomationAuthorityQueryState + /** Usable rows are already cached, so a failure is *stale*, not empty. */ + hasData: boolean +} + +/** + * Query health only. Callers must not fold execution-target state in here — + * a disconnected SSH target leaves its owning authority perfectly listable. + */ +export function resolveAutomationAuthorityHealth( + input: AutomationAuthorityHealthInput +): AutomationAuthorityHealth { + if (!input.compatible) { + return 'incompatible' + } + if (!input.reachable) { + return 'unavailable' + } + if (input.queryState === 'error') { + return 'stale-error' + } + if (input.queryState === 'refreshing') { + return 'refreshing' + } + if (input.queryState === 'loading') { + return input.hasData ? 'refreshing' : 'loading' + } + // Why: idle with nothing cached has not produced a list yet, so it is still loading. + return input.hasData ? 'fresh' : 'loading' +} + +function executionHealthForStatus(status: SshConnectionStatus): AutomationExecutionHealth { + switch (status) { + case 'connected': + return 'connected' + case 'connecting': + case 'reconnecting': + case 'deploying-relay': + return 'connecting' + case 'disconnected': + case 'auth-failed': + case 'reconnection-failed': + case 'error': + return 'disconnected' + } +} + +/** + * Execution health of one SSH target. An unhydrated or removed registration + * reports `unknown`/`unavailable` rather than borrowing a stale status. + */ +export function resolveSshExecutionHealth( + catalogState: AutomationHostCatalogState, + status: SshConnectionStatus | undefined, + missingStatus: SshConnectionStatus | undefined +): AutomationExecutionHealth { + if (catalogState === 'removed') { + return 'unavailable' + } + if (catalogState === 'unhydrated') { + return 'unknown' + } + const effective = status ?? missingStatus + return effective ? executionHealthForStatus(effective) : 'unknown' +} + +/** + * Execution health of an authority's own machine. Offline is `disconnected` + * (reconnectable), never `unavailable`, which is reserved for hosts that are gone. + */ +export function resolveSelfExecutionHealth( + authorityHealth: AutomationAuthorityHealth +): AutomationExecutionHealth { + return authorityHealth === 'unavailable' ? 'disconnected' : 'connected' +} diff --git a/src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts b/src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts new file mode 100644 index 00000000000..d5929f16b62 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts @@ -0,0 +1,145 @@ +/** + * The window event is the only place the publisher's identity can be lost, so + * these cover the whole attribution path: a scoped runtime event must reach one + * host, and an unattributed one must still reach a whole authority. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { + AUTOMATIONS_CHANGED_EVENT, + emitAutomationsChangedWindowEvent +} from '@/lib/automations-changed-window-event' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { createAutomationHostInvalidation } from './automation-host-invalidation' +import { + subscribeAutomationHostInvalidation, + toAutomationAuthorityChangeEvent +} from './automation-host-invalidation-window-events' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } +const RUNTIME_SSH: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' } +} + +function row(id: string): AutomationHostRow { + return { + automation: { id } as Automation, + owner: null, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false + } +} + +function seeded(): AutomationHostCache { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + for (const ref of [DESKTOP_SELF, RUNTIME_SELF, RUNTIME_SSH]) { + cache.commit(cache.beginRequest(ref), { rows: [row('a')] }) + } + return cache +} + +function subscribedCache(): { cache: AutomationHostCache; invalidated: ReturnType } { + const cache = seeded() + const invalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated: invalidated, + schedule: (flush) => flush() + }) + subscribeAutomationHostInvalidation(invalidation.handle) + return { cache, invalidated } +} + +describe('automationsChanged window event attribution', () => { + // The suite runs in node, so the publisher and the subscriber need a real shared target. + beforeEach(() => { + vi.stubGlobal('window', new EventTarget()) + }) + + it('maps an environment id to its runtime authority and keeps the selector', () => { + expect( + toAutomationAuthorityChangeEvent({ + environmentId: 'env-1', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + ).toEqual({ + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + }) + + it('treats an absent environment id as the desktop authority', () => { + expect(toAutomationAuthorityChangeEvent({ reason: 'run' })).toEqual({ + authority: DESKTOP, + reason: 'run' + }) + }) + + it('routes a scoped runtime event to one host and leaves the others alone', () => { + const { cache, invalidated } = subscribedCache() + + emitAutomationsChangedWindowEvent({ + environmentId: 'env-1', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + + expect(invalidated).toHaveBeenCalledWith([hostStableKey(RUNTIME_SSH)]) + expect(cache.get(RUNTIME_SELF)?.requestGeneration).toBe(0) + expect(cache.get(DESKTOP_SELF)?.requestGeneration).toBe(0) + }) + + it('falls back to the whole authority when an old host sends no selector', () => { + const { cache, invalidated } = subscribedCache() + + emitAutomationsChangedWindowEvent({ environmentId: 'env-1' }) + + expect(invalidated.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([hostStableKey(RUNTIME_SELF), hostStableKey(RUNTIME_SSH)]) + ) + expect(cache.get(DESKTOP_SELF)?.requestGeneration).toBe(0) + }) + + // A publisher that predates the detail payload still has to produce a refresh. + it('degrades a bare Event to an unscoped desktop invalidation', () => { + const { cache, invalidated } = subscribedCache() + + window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) + + expect(invalidated).toHaveBeenCalledWith([hostStableKey(DESKTOP_SELF)]) + expect(cache.get(RUNTIME_SELF)?.requestGeneration).toBe(0) + }) + + it('stops delivering once unsubscribed', () => { + const cache = seeded() + const invalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated: invalidated, + schedule: (flush) => flush() + }) + const unsubscribe = subscribeAutomationHostInvalidation(invalidation.handle) + + unsubscribe() + emitAutomationsChangedWindowEvent({ environmentId: 'env-1' }) + + expect(invalidated).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-invalidation-window-events.ts b/src/renderer/src/components/automations/automation-host-invalidation-window-events.ts new file mode 100644 index 00000000000..dab76c78dbf --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation-window-events.ts @@ -0,0 +1,41 @@ +/** + * Adapts the renderer's `automationsChanged` window event to the invalidation + * model's authority events. + * + * The publisher's authority is the environment it arrived from — desktop when + * none is named. An event that carries no selector still invalidates that one + * authority, which is what keeps an old host (or an unattributed local write) + * refreshing rather than being dropped. + */ + +import { + AUTOMATIONS_CHANGED_EVENT, + automationsChangedWindowDetail, + type AutomationsChangedWindowDetail +} from '@/lib/automations-changed-window-event' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationAuthorityChangeEvent } from './automation-host-invalidation' + +export function toAutomationAuthorityChangeEvent( + detail: AutomationsChangedWindowDetail +): AutomationAuthorityChangeEvent { + const authority: StableAutomationAuthorityRef = detail.environmentId + ? { kind: 'runtime', environmentId: detail.environmentId } + : { kind: 'desktop' } + return { + authority, + ...(detail.selector ? { selector: detail.selector } : {}), + ...(detail.reason ? { reason: detail.reason } : {}) + } +} + +export function subscribeAutomationHostInvalidation( + handle: (event: AutomationAuthorityChangeEvent) => void, + target: EventTarget = window +): () => void { + const listener = (event: Event): void => { + handle(toAutomationAuthorityChangeEvent(automationsChangedWindowDetail(event))) + } + target.addEventListener(AUTOMATIONS_CHANGED_EVENT, listener) + return () => target.removeEventListener(AUTOMATIONS_CHANGED_EVENT, listener) +} diff --git a/src/renderer/src/components/automations/automation-host-invalidation.test.ts b/src/renderer/src/components/automations/automation-host-invalidation.test.ts new file mode 100644 index 00000000000..cfeb2db0902 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation.test.ts @@ -0,0 +1,147 @@ +/** + * Event invalidation has one blast-radius rule: a scoped event touches its own + * entry, an unscoped one touches its authority, and neither ever touches + * another authority's rows. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { createAutomationHostInvalidation } from './automation-host-invalidation' + +const DESKTOP: StableAutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const DESKTOP_SELF: StableAutomationCatalogRef = { authority: DESKTOP, selector: { kind: 'self' } } +const DESKTOP_SSH: StableAutomationCatalogRef = { + authority: DESKTOP, + selector: { kind: 'ssh', targetId: 'target-1' } +} +const RUNTIME_SELF: StableAutomationCatalogRef = { authority: RUNTIME, selector: { kind: 'self' } } + +function row(id: string): AutomationHostRow { + return { + automation: { id } as Automation, + owner: null, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: false + } +} + +function seeded(): AutomationHostCache { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + for (const ref of [DESKTOP_SELF, DESKTOP_SSH, RUNTIME_SELF]) { + cache.commit(cache.beginRequest(ref), { rows: [row('a')] }) + } + return cache +} + +function generationOf(cache: AutomationHostCache, ref: StableAutomationCatalogRef): number { + return cache.get(ref)?.requestGeneration ?? -1 +} + +describe('automation host invalidation', () => { + it('invalidates only the entry a scoped event names', () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated, + schedule: (f) => f() + }) + const before = generationOf(cache, DESKTOP_SELF) + + invalidation.handle({ authority: DESKTOP, selector: { kind: 'ssh', targetId: 'target-1' } }) + + expect(onInvalidated).toHaveBeenCalledWith([hostStableKey(DESKTOP_SSH)]) + expect(generationOf(cache, DESKTOP_SELF)).toBe(before) + }) + + it('invalidates every entry of one authority for an unscoped event, and no other authority', () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ + cache, + onInvalidated, + schedule: (f) => f() + }) + const untouched = generationOf(cache, RUNTIME_SELF) + + invalidation.handle({ authority: DESKTOP, reason: 'definition' }) + + expect(onInvalidated.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([hostStableKey(DESKTOP_SELF), hostStableKey(DESKTOP_SSH)]) + ) + expect(onInvalidated.mock.calls[0]?.[0]).not.toContain(hostStableKey(RUNTIME_SELF)) + expect(generationOf(cache, RUNTIME_SELF)).toBe(untouched) + }) + + it('coalesces a burst into one invalidation pass', async () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + + invalidation.handle({ authority: DESKTOP, selector: { kind: 'self' }, reason: 'definition' }) + invalidation.handle({ authority: DESKTOP, selector: { kind: 'self' }, reason: 'run' }) + invalidation.handle({ authority: DESKTOP, selector: { kind: 'ssh', targetId: 'target-1' } }) + expect(invalidation.pending()).toBe(2) + expect(onInvalidated).not.toHaveBeenCalled() + + await Promise.resolve() + expect(onInvalidated).toHaveBeenCalledTimes(1) + expect(onInvalidated.mock.calls[0]?.[0]).toHaveLength(2) + // Why: one refresh per burst, so an entry's generation advances once, not three times. + expect(generationOf(cache, DESKTOP_SELF)).toBe(1) + }) + + it('folds a scoped event into the authority pass it is already covered by', async () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + + invalidation.handle({ authority: DESKTOP }) + invalidation.handle({ authority: DESKTOP, selector: { kind: 'self' } }) + await Promise.resolve() + + expect(onInvalidated.mock.calls[0]?.[0]).toHaveLength(2) + expect(generationOf(cache, DESKTOP_SELF)).toBe(1) + }) + + it('reports a scoped event for a host nothing is caching yet', async () => { + const cache = seeded() + const uncached: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'never-seen' } + } + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + + // A create is the first thing that ever addresses its host: dropping the key + // for want of a stale entry is how the new row never appears. + invalidation.handle({ authority: RUNTIME, selector: { kind: 'ssh', targetId: 'never-seen' } }) + await Promise.resolve() + + expect(onInvalidated).toHaveBeenCalledWith([hostStableKey(uncached)]) + expect(cache.get(uncached)).toBeNull() + expect(generationOf(cache, RUNTIME_SELF)).toBe(0) + }) + + it('drops queued work once disposed', async () => { + const cache = seeded() + const onInvalidated = vi.fn() + const invalidation = createAutomationHostInvalidation({ cache, onInvalidated }) + invalidation.handle({ authority: DESKTOP }) + invalidation.dispose() + await Promise.resolve() + expect(onInvalidated).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-invalidation.ts b/src/renderer/src/components/automations/automation-host-invalidation.ts new file mode 100644 index 00000000000..77e7655f8b7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-invalidation.ts @@ -0,0 +1,122 @@ +/** + * Turns `automationsChanged` events into cache invalidations. + * + * A scoped event invalidates its one entry; an older or unscoped event + * invalidates every entry of *that authority only*, never the whole cache — one + * chatty host must not evict the rows of every other host. Bursts collapse into + * a single microtask so a run that writes definition, run, and usage events in + * a row costs one refresh, and an old runtime's shared legacy request is issued + * once for the whole burst. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import type { AutomationHostCache } from './automation-host-cache' + +/** What changed on the host. Carried for diagnostics; every reason invalidates alike. */ +export type AutomationAuthorityChangeReason = 'definition' | 'run' | 'usage' + +/** The publisher payload plus the authority the subscriber resolved it from. */ +export type AutomationAuthorityChangeEvent = { + authority: StableAutomationAuthorityRef + selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } + reason?: AutomationAuthorityChangeReason +} + +export type AutomationHostInvalidationOptions = { + cache: AutomationHostCache + /** Called once per coalesced burst with the entries that need refetching. */ + onInvalidated?: (stableKeys: readonly string[]) => void + schedule?: (flush: () => void) => void +} + +export type AutomationHostInvalidation = { + handle: (event: AutomationAuthorityChangeEvent) => void + flush: () => void + pending: () => number + dispose: () => void +} + +function catalogRefFor(event: AutomationAuthorityChangeEvent): StableAutomationCatalogRef | null { + if (!event.selector) { + return null + } + if (event.selector.kind === 'ssh') { + return { + authority: event.authority, + selector: { kind: 'ssh', targetId: event.selector.targetId } + } + } + return event.selector.kind === 'orphan' + ? { authority: event.authority, selector: { kind: 'orphan' } } + : { authority: event.authority, selector: { kind: 'self' } } +} + +export function createAutomationHostInvalidation( + options: AutomationHostInvalidationOptions +): AutomationHostInvalidation { + const schedule = options.schedule ?? queueMicrotask + const scopedKeys = new Set() + const authorities = new Map() + let scheduled = false + let disposed = false + + const flush = (): void => { + scheduled = false + if (disposed) { + return + } + const invalidated = new Set() + for (const authority of authorities.values()) { + for (const key of options.cache.invalidateAuthority(authority)) { + invalidated.add(key) + } + } + authorities.clear() + for (const key of scopedKeys) { + if (invalidated.has(key)) { + continue + } + // A key with no entry has nothing stale to discard, but a create lands on + // exactly such a host — dropping it here is how a new row never appears. + // Report it either way and let the catalog decide whether it is fetchable. + if (options.cache.getByKey(key)) { + options.cache.invalidateKey(key) + } + invalidated.add(key) + } + scopedKeys.clear() + if (invalidated.size > 0) { + options.onInvalidated?.([...invalidated]) + } + } + + return { + handle: (event) => { + if (disposed) { + return + } + const ref = catalogRefFor(event) + if (ref) { + scopedKeys.add(hostStableKey(ref)) + } else { + authorities.set(automationAuthorityCatalogKey(event.authority), event.authority) + } + if (!scheduled) { + scheduled = true + schedule(flush) + } + }, + flush, + pending: () => scopedKeys.size + authorities.size, + dispose: () => { + disposed = true + scopedKeys.clear() + authorities.clear() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-list-rows.test.ts b/src/renderer/src/components/automations/automation-host-list-rows.test.ts new file mode 100644 index 00000000000..afe4720c316 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-list-rows.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCacheEntry, AutomationHostRow } from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + filterAutomationHostGroups, + resolveAutomationHostListRows +} from './automation-host-list-rows' + +function entry( + overrides: Partial & Pick +): AutomationHostCatalogEntry { + return { + owner: null, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +const DESKTOP_SELF = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } +}) +const DESKTOP_ORPHAN = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, + stableKey: 'host:desktop:orphan', + label: 'Unassigned legacy automations', + kind: 'orphan' +}) +const RUNTIME_SELF = entry({ + stableRef: { authority: { kind: 'runtime', environmentId: 'gpu' }, selector: { kind: 'self' } }, + stableKey: 'host:runtime:gpu:self', + label: 'GPU box', + authorityLabel: 'GPU box', + owner: { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 4 }, + selector: { kind: 'self' } + } +}) + +const RUNTIME_SSH = entry({ + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 'web-01' } + }, + stableKey: 'host:runtime:gpu:ssh:web-01', + label: 'web-01', + authorityLabel: 'GPU box', + kind: 'ssh', + owner: { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 4 }, + selector: { kind: 'ssh', targetId: 'web-01', targetGeneration: 2 } + } +}) + +function automation(id: string, name = id): Automation { + return { id, name } as Automation +} + +function row(id: string, owner: AutomationHostRow['owner'], name = id): AutomationHostRow { + return { + automation: automation(id, name), + owner, + selector: owner ? { kind: 'self' } : { kind: 'orphan', issue: 'missing_repo' }, + usageSummary: null, + usageKnown: false + } +} + +function cached(rows: AutomationHostRow[], fetchedAt: number | null = 1): AutomationHostCacheEntry { + return { + data: rows, + fetchedAt, + attempt: 0, + requestGeneration: 0, + catalogGeneration: 0, + request: null, + error: null, + orphanCount: null + } +} + +function catalogOf(entries: AutomationHostCatalogEntry[]): AutomationHostCatalog { + return { + entries, + byStableKey: new Map(entries.map((candidate) => [candidate.stableKey, candidate])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } +} + +const ALL_HOSTS: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false +} + +describe('automation host list rows', () => { + it('reports nothing answered until a host has actually committed', () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF]), + resolution: ALL_HOSTS, + entry: () => cached([], null) + }) + + // Why this matters: the caller keeps its own list while this is false, so a + // never-answered host must not read as an empty one. + expect(rows.answered).toBe(false) + expect(rows.automations).toEqual([]) + }) + + it('keeps the owned copy when the orphan scope returns the same record', () => { + const owned = row('a-1', DESKTOP_SELF.owner) + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, DESKTOP_ORPHAN]), + resolution: ALL_HOSTS, + entry: (stableKey) => cached(stableKey === 'host:desktop:self' ? [owned] : [row('a-1', null)]) + }) + + expect(rows.automations).toHaveLength(1) + expect(rows.capturedOwners.get(rows.rows[0]!.key)?.owner).toEqual(DESKTOP_SELF.owner) + }) + + it('keeps both authorities’ copies of one automation ID', () => { + // An ID is unique only inside its authority (doc:38), so the desktop and a + // runtime-owned SSH host may both hold `a-1`. Dropping either is a row the + // user stored and can no longer see. + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SSH]), + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === 'host:desktop:self' + ? row('a-1', DESKTOP_SELF.owner, 'Nightly desktop') + : row('a-1', RUNTIME_SSH.owner, 'Nightly web-01') + ]) + }) + + expect(rows.automations.map((item) => item.name)).toEqual(['Nightly desktop', 'Nightly web-01']) + // Each copy keeps its own owner: a bare-ID map would hand both rows the last host's. + expect(rows.rows.map((item) => rows.capturedOwners.get(item.key)?.owner)).toEqual([ + DESKTOP_SELF.owner, + RUNTIME_SSH.owner + ]) + }) + + it("carries each row's host of origin so two authorities stay distinguishable", () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SELF]), + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === 'host:desktop:self' + ? row('local-1', DESKTOP_SELF.owner) + : row('gpu-1', RUNTIME_SELF.owner) + ]) + }) + + expect(rows.rows.map((item) => [item.automation.id, item.hostLabel])).toEqual([ + ['local-1', 'This computer'], + ['gpu-1', 'GPU box'] + ]) + expect(rows.capturedOwners.get(rows.rows[1]!.key)?.owner).toEqual(RUNTIME_SELF.owner) + expect(rows.groups.map((group) => group.authorityKey)).toEqual([ + 'authority:desktop', + 'authority:runtime:gpu' + ]) + }) + + it('applies the search to grouped rows but keeps every host on screen', () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SELF]), + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === 'host:desktop:self' + ? row('local-1', DESKTOP_SELF.owner) + : row('gpu-1', RUNTIME_SELF.owner) + ]) + }) + const visible = rows.rows + .filter((item) => item.automation.id === 'gpu-1') + .map((item) => item.key) + const filtered = filterAutomationHostGroups(rows.groups, new Set(visible)) + + // Why every host survives: a query hiding a host's rows is not the host going away. + expect(filtered).toHaveLength(2) + expect(filtered[0].hosts[0].rows).toEqual([]) + expect(filtered[1].hosts[0].rows.map((item) => item.automation.id)).toEqual(['gpu-1']) + }) + + it('renders one host without group headers', () => { + const rows = resolveAutomationHostListRows({ + catalog: catalogOf([DESKTOP_SELF, RUNTIME_SELF]), + resolution: { + effective: { kind: 'host', host: DESKTOP_SELF.stableRef }, + entry: DESKTOP_SELF, + status: 'ready', + announceFallback: false + }, + entry: () => cached([row('a-1', DESKTOP_SELF.owner)]) + }) + + expect(rows.groups).toEqual([]) + expect(rows.automations.map((item) => item.id)).toEqual(['a-1']) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-list-rows.ts b/src/renderer/src/components/automations/automation-host-list-rows.ts new file mode 100644 index 00000000000..3ae397cc535 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-list-rows.ts @@ -0,0 +1,168 @@ +/** + * Turns cached per-host rows into the list the page renders. + * + * The rows keep their host of origin all the way through, because under All + * hosts two authorities can hold records with the same name and only the + * originating host explains which is which. `answered` is the honest gate: until + * some host in view has actually committed a response, this projection has + * nothing to say and the caller keeps whatever it already had. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCacheEntry } from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + captureAutomationOwners, + type AutomationCapturedOwner, + type AutomationCapturedRow +} from './automation-captured-owner' +import { + automationAuthorityRecordKey, + automationListRowKey, + type AutomationListRow +} from './automation-list-row-identity' + +export type AutomationHostGroupHost = { + entry: AutomationHostCatalogEntry + rows: readonly AutomationListRow[] +} + +export type AutomationHostGroup = { + authorityKey: string + authorityLabel: string + hosts: readonly AutomationHostGroupHost[] +} + +export type AutomationHostListRows = { + /** + * Catalog-ordered, deduplicated per authority. Each row carries its own host + * label and usage, because two authorities may hold the same automation ID and + * a map keyed by that ID can only describe one of them. + */ + rows: readonly AutomationListRow[] + /** The same rows' records, for callers that only need the automations. */ + automations: readonly Automation[] + /** Keyed by `AutomationListRow.key`; a bare ID cannot name one of two hosts' copies. */ + capturedOwners: ReadonlyMap + /** Populated only under All hosts; a single-host view needs no group headers. */ + groups: readonly AutomationHostGroup[] + /** False until a host in view committed a response, cached or fresh. */ + answered: boolean +} + +export type AutomationHostListRowsInput = { + catalog: AutomationHostCatalog + resolution: AutomationHostFilterResolution + entry: (stableKey: string) => AutomationHostCacheEntry | null +} + +const EMPTY_ROWS: AutomationHostListRows = { + rows: [], + automations: [], + capturedOwners: new Map(), + groups: [], + answered: false +} + +export type AutomationHostGroupVisibleHost = AutomationHostGroupHost & { + /** Rows the host had before the query, so an emptied group can name the query as the cause. */ + hostRowCount: number +} + +export type AutomationHostVisibleGroup = Omit & { + hosts: readonly AutomationHostGroupVisibleHost[] +} + +/** + * Applies the list search to the grouped view. Empty groups are kept: a host + * whose rows the query hid still owes the user its status row, and dropping it + * would make a search look like a host disappearing. + */ +export function filterAutomationHostGroups( + groups: readonly AutomationHostGroup[], + visibleRowKeys: ReadonlySet +): readonly AutomationHostVisibleGroup[] { + return groups.map((group) => ({ + ...group, + hosts: group.hosts.map((host) => ({ + ...host, + hostRowCount: host.rows.length, + rows: host.rows.filter((row) => visibleRowKeys.has(row.key)) + })) + })) +} + +/** Which hosts the current filter puts on screen, in catalog order. */ +export function automationHostEntriesInView( + input: Pick +): readonly AutomationHostCatalogEntry[] { + if (input.resolution.effective.kind === 'all') { + return input.catalog.entries + } + return input.resolution.entry ? [input.resolution.entry] : [] +} + +export function resolveAutomationHostListRows( + input: AutomationHostListRowsInput +): AutomationHostListRows { + const entries = automationHostEntriesInView(input) + if (entries.length === 0) { + return EMPTY_ROWS + } + const rows: AutomationListRow[] = [] + const seen = new Set() + const groups: AutomationHostGroup[] = [] + const groupByKey = new Map() + const capturedRows: AutomationCapturedRow[] = [] + let answered = false + + for (const entry of entries) { + const cached = input.entry(entry.stableKey) + if (cached && cached.fetchedAt !== null) { + answered = true + } + const authorityKey = automationAuthorityCatalogKey(entry.stableRef.authority) + const hostRows: AutomationListRow[] = [] + for (const row of cached?.data ?? []) { + // Scoped to the authority: another authority's identically named record is + // a different record, and dropping it would hide a row the user stored. + const recordKey = automationAuthorityRecordKey(authorityKey, row.automation.id) + if (seen.has(recordKey)) { + continue + } + seen.add(recordKey) + const listRow: AutomationListRow = { + key: automationListRowKey(entry.stableKey, row.automation.id), + automation: row.automation, + hostLabel: entry.label, + usageSummary: row.usageSummary ?? null + } + capturedRows.push({ rowKey: listRow.key, row }) + rows.push(listRow) + hostRows.push(listRow) + } + let group = groupByKey.get(authorityKey) + if (!group) { + group = { authorityKey, authorityLabel: entry.authorityLabel, hosts: [] } + groupByKey.set(authorityKey, group) + groups.push(group) + } + group.hosts.push({ entry, rows: hostRows }) + } + + return { + rows, + automations: rows.map((row) => row.automation), + // Catalog order decides duplicates, and it places an authority's Self and + // SSH hosts before its orphan bucket — so a record keeps its owner rather + // than the ownerless copy the orphan scope also returns. + capturedOwners: captureAutomationOwners(capturedRows), + groups: input.resolution.effective.kind === 'all' ? groups : [], + answered + } +} diff --git a/src/renderer/src/components/automations/automation-host-orphan-entry.test.ts b/src/renderer/src/components/automations/automation-host-orphan-entry.test.ts new file mode 100644 index 00000000000..87ff14a8c32 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-orphan-entry.test.ts @@ -0,0 +1,216 @@ +/** + * How an authority's orphan bucket becomes visible at all. + * + * Two independent ways it fails to appear, and fixing one leaves the other: a + * scoped answer that omits `orphanCount` must stay unsettled rather than commit + * a fabricated zero, and an old runtime's unscoped answer must report the count + * it just computed so the entry can bootstrap without an orphan request that + * only exists once the entry does. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from '../../../../shared/automation-list-scope' +import { validateAutomationListResponse } from '../../../../shared/automation-list-response' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache } from './automation-host-cache' +import { createAutomationHostQueryController } from './automation-host-cache-controller' +import { + AUTOMATION_ORPHAN_ENTRY_LABEL, + buildAutomationHostCatalog +} from './automation-host-catalog' +import { resetAutomationHostCatalogGenerationsForTests } from './automation-host-catalog-generation' +import { + automationAuthorityCatalogKey, + type AutomationCatalogSshMirrorInput, + type AutomationHostCatalog, + type AutomationHostQuerySupport +} from './automation-host-catalog-types' +import { resolveAutomationHostFilter } from './automation-host-filter-resolution' +import type { AutomationHostSchedulerTransport } from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const RUNTIME: StableAutomationAuthorityRef = { kind: 'runtime', environmentId: 'env-1' } +const RUNTIME_ORPHAN: StableAutomationCatalogRef = { + authority: RUNTIME, + selector: { kind: 'orphan' } +} +const RUNTIME_ORPHAN_KEY = hostStableKey(RUNTIME_ORPHAN) +const RUNTIME_AUTHORITY_KEY = automationAuthorityCatalogKey(RUNTIME) + +const EMPTY_SSH: AutomationCatalogSshMirrorInput = { + targetsHydrated: true, + targets: [], + removedTargetLabels: new Map(), + connectionStatusByTargetId: new Map() +} + +function catalogWith( + orphanCount: number | null, + querySupport: AutomationHostQuerySupport, + referencedStableKeys: readonly string[] = [] +): AutomationHostCatalog { + return buildAutomationHostCatalog({ + desktop: { label: 'Desktop', ssh: EMPTY_SSH }, + runtimes: [ + { + environmentId: 'env-1', + label: 'Runtime', + pairingRevision: 2, + authorityHealth: 'fresh', + querySupport, + ssh: EMPTY_SSH, + ...(orphanCount === null ? {} : { orphanCount }) + } + ], + runtimeCatalogSettled: true, + referencedStableKeys + }) +} + +/** A local record whose project this client cannot resolve; #49's `projectUnverified` case. */ +function unverifiedAutomation(id: string): Automation { + return { id, name: id, projectId: 'repo-gone', executionTargetType: 'local' } as Automation +} + +/** Goes through the real validator so an omitted `orphanCount` is omitted the way a host omits it. */ +function scopedAnswer(raw: Record): ScopedAutomationList { + const validation = validateAutomationListResponse(raw, { kind: 'self' }) + if (!validation.ok) { + throw new Error(validation.error.message) + } + return { ...validation.result, invalidRows: validation.invalidRows } +} + +function createController(transport: Partial) { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const controller = createAutomationHostQueryController({ + cache, + // A runtime whose repos this client has not mirrored: every project lookup misses. + legacyPartitionContext: () => ({ + repoConnectionId: () => undefined, + projectsAuthoritative: false + }), + isVisible: () => true, + schedule: (flush) => flush(), + transport + }) + return { cache, controller } +} + +afterEach(() => { + resetAutomationHostCatalogGenerationsForTests() +}) + +describe('an authority that reports no orphan count', () => { + it('leaves the count unreported rather than parsing it as zero', () => { + const validation = validateAutomationListResponse( + { automations: [{ id: 'a1' }], items: [{ automationId: 'a1', selector: { kind: 'self' } }] }, + { kind: 'self' } + ) + expect( + validation.ok && validation.result.orphanCount, + 'an omitted orphanCount was committed as an authoritative zero' + ).toBeUndefined() + }) + + it('keeps the authority unsettled instead of claiming it has no orphans', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const { controller } = createController({ listScoped }) + + await controller.applyCatalog(catalogWith(null, 'scoped')) + + expect( + controller.authorityOrphanCount(RUNTIME), + 'an authority that reported no orphanCount was recorded as having zero orphans' + ).toBeNull() + controller.dispose() + }) + + it('retains a persisted orphan selection as loading rather than dropping it', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const { controller } = createController({ listScoped }) + await controller.applyCatalog(catalogWith(null, 'scoped')) + + const catalog = catalogWith(controller.authorityOrphanCount(RUNTIME), 'scoped') + expect( + catalog.hydration.orphanSettledAuthorityKeys.has(RUNTIME_AUTHORITY_KEY), + 'the orphan bucket settled on an authority that never reported a count' + ).toBe(false) + + const filter: AutomationHostFilter = { kind: 'host', host: RUNTIME_ORPHAN } + const resolution = resolveAutomationHostFilter({ filter, catalog }) + expect( + resolution.status, + 'a persisted orphan selection was dropped without an authoritative count' + ).toBe('loading') + expect(resolution.announceFallback).toBe(false) + controller.dispose() + }) +}) + +describe('an old runtime answering with one unscoped list', () => { + it('bootstraps the orphan entry from projectUnverified rows it never requested', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const listLegacy = vi.fn(() => + Promise.resolve([unverifiedAutomation('a1'), unverifiedAutomation('a2')]) + ) + const { cache, controller } = createController({ listScoped, listLegacy }) + + // The catalog cannot contain an orphan entry yet: nothing has reported one. + const first = catalogWith(null, 'legacy-unscoped') + expect(first.byStableKey.has(RUNTIME_ORPHAN_KEY)).toBe(false) + await controller.applyCatalog(first) + + expect( + controller.authorityOrphanCount(RUNTIME), + 'the unscoped answer classified rows as orphans but reported no count for the authority' + ).toBe(2) + + const second = catalogWith(controller.authorityOrphanCount(RUNTIME), 'legacy-unscoped') + const orphanEntry = second.byStableKey.get(RUNTIME_ORPHAN_KEY) + expect( + orphanEntry?.label, + 'the orphan entry never materialised, so the old runtime shows zero automations' + ).toBe(AUTOMATION_ORPHAN_ENTRY_LABEL) + expect(second.hydration.orphanSettledAuthorityKeys.has(RUNTIME_AUTHORITY_KEY)).toBe(true) + + await controller.applyCatalog(second) + const orphanRows = cache.getByKey(RUNTIME_ORPHAN_KEY)?.data ?? [] + expect(orphanRows.map((row) => row.automation.id)).toEqual(['a1', 'a2']) + expect(orphanRows.map((row) => row.selector)).toEqual([ + { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified }, + { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified } + ]) + controller.dispose() + }) + + // Already worked before the bootstrap fix: a persisted orphan selection puts its + // own entry in the catalog, which is what got the orphan request sent at all. + it('settles a selected orphan entry the selection itself put in the catalog', async () => { + const listScoped = vi.fn(() => Promise.resolve(scopedAnswer({ automations: [], items: [] }))) + const listLegacy = vi.fn(() => Promise.resolve([unverifiedAutomation('a1')])) + const { controller } = createController({ listScoped, listLegacy }) + + const filter: AutomationHostFilter = { kind: 'host', host: RUNTIME_ORPHAN } + await controller.applyCatalog(catalogWith(null, 'legacy-unscoped', [RUNTIME_ORPHAN_KEY])) + + const catalog = catalogWith(controller.authorityOrphanCount(RUNTIME), 'legacy-unscoped', [ + RUNTIME_ORPHAN_KEY + ]) + const resolution = resolveAutomationHostFilter({ filter, catalog }) + expect( + resolution.status, + 'a persisted orphan selection stayed loading forever after an old-server unscoped list' + ).toBe('ready') + controller.dispose() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-picker-groups.test.ts b/src/renderer/src/components/automations/automation-host-picker-groups.test.ts new file mode 100644 index 00000000000..bf4632c607f --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-picker-groups.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + ALL_HOSTS_OPTION_VALUE, + automationHostFilterForEntry, + automationHostSearchText, + groupAutomationHostEntriesByAuthority +} from './automation-host-picker-groups' + +function desktopSsh(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' + } +} + +describe('automation host picker groups', () => { + it('groups by authority while preserving the shared catalog order', () => { + const entries = [ + runtimeSelf('env-b', 'Build box'), + desktopSsh('t2', 'web-02'), + desktopSsh('t1', 'web-01'), + runtimeSelf('env-a', 'Alpha box') + ] + const groups = groupAutomationHostEntriesByAuthority(entries) + + expect(groups.map((group) => group.authorityLabel)).toEqual([ + 'This computer', + 'Alpha box', + 'Build box' + ]) + expect(groups.flatMap((group) => group.entries).map((entry) => entry.stableKey)).toEqual( + orderAutomationHostCatalogEntries(entries).map((entry) => entry.stableKey) + ) + }) + + it('keeps two authorities with equal target ids in separate groups', () => { + const collidingRuntime: AutomationHostCatalogEntry = { + ...desktopSsh('t1', 'web-01'), + stableRef: { + authority: { kind: 'runtime', environmentId: 'env-a' }, + selector: { kind: 'ssh', targetId: 't1' } + }, + stableKey: 'host:runtime:env-a:ssh:t1', + authorityLabel: 'Alpha box' + } + const groups = groupAutomationHostEntriesByAuthority([ + desktopSsh('t1', 'web-01'), + collidingRuntime + ]) + + expect(groups).toHaveLength(2) + expect(groups.map((group) => group.entries.length)).toEqual([1, 1]) + }) + + it('builds a stable filter that carries no incarnation', () => { + expect(automationHostFilterForEntry(desktopSsh('t1', 'web-01'))).toEqual({ + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } } + }) + }) + + it('uses an All hosts sentinel that cannot collide with a host stable key', () => { + const keys = [desktopSsh('t1', 'web-01'), runtimeSelf('env-a', 'Alpha box')].map( + (entry) => entry.stableKey + ) + expect(keys).not.toContain(ALL_HOSTS_OPTION_VALUE) + expect(keys.every((key) => key.startsWith('host:'))).toBe(true) + }) + + it('indexes search text over both the host and its authority', () => { + expect(automationHostSearchText(runtimeSelf('env-a', 'Alpha Box'))).toBe('alpha box alpha box') + expect(automationHostSearchText(desktopSsh('t1', 'WEB-01'))).toBe('this computer web-01') + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-picker-groups.ts b/src/renderer/src/components/automations/automation-host-picker-groups.ts new file mode 100644 index 00000000000..55784c0b6a2 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-picker-groups.ts @@ -0,0 +1,51 @@ +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { orderAutomationHostCatalogEntries } from './automation-host-catalog-order' + +/** Sentinel for the All hosts option; stable keys are `host:`-namespaced, so it cannot collide. */ +export const ALL_HOSTS_OPTION_VALUE = '__all_automation_hosts__' + +export type AutomationHostPickerGroup = { + authorityKey: string + authorityLabel: string + entries: AutomationHostCatalogEntry[] +} + +/** + * Groups by authority while preserving the one deterministic catalog order, so + * the picker and the All-hosts list never disagree about position. + */ +export function groupAutomationHostEntriesByAuthority( + entries: readonly AutomationHostCatalogEntry[] +): AutomationHostPickerGroup[] { + const ordered = orderAutomationHostCatalogEntries(entries) + const groups: AutomationHostPickerGroup[] = [] + const byKey = new Map() + + for (const entry of ordered) { + const authorityKey = automationAuthorityCatalogKey(entry.stableRef.authority) + let group = byKey.get(authorityKey) + if (!group) { + group = { authorityKey, authorityLabel: entry.authorityLabel, entries: [] } + byKey.set(authorityKey, group) + groups.push(group) + } + group.entries.push(entry) + } + return groups +} + +export function automationHostFilterForEntry( + entry: AutomationHostCatalogEntry +): AutomationHostFilter { + return { kind: 'host', host: entry.stableRef } +} + +/** + * Text a searchable picker matches against. Built once per entry by the caller, + * never inside a comparator. + */ +export function automationHostSearchText(entry: AutomationHostCatalogEntry): string { + return `${entry.authorityLabel} ${entry.label}`.toLocaleLowerCase() +} diff --git a/src/renderer/src/components/automations/automation-host-recovery.test.ts b/src/renderer/src/components/automations/automation-host-recovery.test.ts new file mode 100644 index 00000000000..8d6419fb128 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-recovery.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + runAutomationHostRecovery, + type AutomationHostRecoveryDeps +} from './automation-host-recovery' + +function entry( + overrides: Partial & Pick +): AutomationHostCatalogEntry { + return { + owner: null, + stableKey: 'host', + label: 'Host', + authorityLabel: 'Authority', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function deps() { + return { + retry: vi.fn(), + connectSshTarget: vi.fn(), + connectRuntimeEnvironment: vi.fn(), + openSettings: vi.fn() + } +} + +const DESKTOP_SSH = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + kind: 'ssh' +}) +const RUNTIME_SSH = entry({ + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 't1' } + }, + kind: 'ssh' +}) + +describe('automation host recovery', () => { + it('retries the host rather than the whole page', () => { + const target = deps() + runAutomationHostRecovery('retry', DESKTOP_SSH, target) + expect(target.retry).toHaveBeenCalledWith(DESKTOP_SSH) + }) + + it('dials the SSH target when the authority is fine', () => { + const target = deps() + runAutomationHostRecovery('reconnect', DESKTOP_SSH, target) + expect(target.connectSshTarget).toHaveBeenCalledWith('t1') + expect(target.connectRuntimeEnvironment).not.toHaveBeenCalled() + }) + + it('dials the runtime first when the server itself is unreachable', () => { + const target = deps() + // Why: an unreachable server cannot be asked to dial its own SSH target. + runAutomationHostRecovery( + 'reconnect', + { ...RUNTIME_SSH, authorityHealth: 'unavailable' }, + target + ) + expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith('gpu') + expect(target.connectSshTarget).not.toHaveBeenCalled() + }) + + it('re-asks a desktop Self host, which has no transport to dial', () => { + const target = deps() + const desktopSelf = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + }) + runAutomationHostRecovery('reconnect', desktopSelf, target) + expect(target.retry).toHaveBeenCalledWith(desktopSelf) + }) + + it('sends a runtime to the servers pane, since nothing here updates a host', () => { + const target = deps() + runAutomationHostRecovery('update-server', RUNTIME_SSH, target) + expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null }) + }) + + it('sends a stale desktop SSH registration to the SSH pane instead', () => { + const target = deps() + runAutomationHostRecovery('update-server', DESKTOP_SSH, target) + expect(target.openSettings).toHaveBeenCalledWith({ pane: 'ssh', repoId: null }) + }) + + it('does nothing without an entry to act on', () => { + const target = deps() + runAutomationHostRecovery('retry', null, target) + expect(target.retry).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-recovery.ts b/src/renderer/src/components/automations/automation-host-recovery.ts new file mode 100644 index 00000000000..bc71f08ff18 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-recovery.ts @@ -0,0 +1,70 @@ +/** + * Turns a recovery verb into the one action that can actually fix it. + * + * The verbs come from `automation-host-status-descriptors.ts` and are never + * invented here; this module only decides *what* Retry, Reconnect, and Update + * server mean for a given host. Reconnect dials the thing that is down — the + * runtime transport or the SSH target — and Update server can only take the user + * to where the version is managed, because nothing in the app updates a host. + */ + +import type { SettingsNavigationTarget } from '@/lib/settings-navigation-types' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' + +export type AutomationHostRecoveryDeps = { + /** Forces one fresh query for this host, bypassing TTL and retry cooldown. */ + retry: (entry: AutomationHostCatalogEntry) => void + connectSshTarget: (targetId: string) => void + connectRuntimeEnvironment: (environmentId: string) => void + openSettings: (target: SettingsNavigationTarget) => void +} + +/** Where a host's version is managed; the app cannot update a host on its own. */ +function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavigationTarget { + if (entry.stableRef.authority.kind === 'runtime') { + return { pane: 'servers', repoId: null } + } + // A desktop SSH host with no registration generation is a stale registration, + // repaired by re-adding the target rather than by updating anything. + return { pane: entry.stableRef.selector.kind === 'ssh' ? 'ssh' : 'automations', repoId: null } +} + +function reconnect(entry: AutomationHostCatalogEntry, deps: AutomationHostRecoveryDeps): void { + const authority = entry.stableRef.authority + // Authority first: an unreachable server cannot be asked to dial its own targets. + if (authority.kind === 'runtime' && entry.authorityHealth === 'unavailable') { + deps.connectRuntimeEnvironment(authority.environmentId) + return + } + if (entry.stableRef.selector.kind === 'ssh') { + deps.connectSshTarget(entry.stableRef.selector.targetId) + return + } + if (authority.kind === 'runtime') { + deps.connectRuntimeEnvironment(authority.environmentId) + return + } + // Desktop Self has no transport to dial, so the only honest fallback is to re-ask. + deps.retry(entry) +} + +export function runAutomationHostRecovery( + action: AutomationHostRecoveryAction, + entry: AutomationHostCatalogEntry | null, + deps: AutomationHostRecoveryDeps +): void { + if (!entry) { + return + } + switch (action) { + case 'retry': + deps.retry(entry) + return + case 'reconnect': + reconnect(entry, deps) + return + case 'update-server': + deps.openSettings(versionSettingsTarget(entry)) + } +} diff --git a/src/renderer/src/components/automations/automation-host-request-instrumentation.ts b/src/renderer/src/components/automations/automation-host-request-instrumentation.ts new file mode 100644 index 00000000000..65ca85e483a --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-request-instrumentation.ts @@ -0,0 +1,108 @@ +/** + * The scheduler's side of the cache counters: it knows which fence a call was + * made under, and this knows what that means to the release gate. + * + * Keeping the translation here is what lets the scheduler stay a description of + * when to fetch rather than of how each fetch is counted. The rule it enforces + * on the scheduler's behalf: a legacy call is one request for its authority and + * belongs to no stable key, on every counter that has a keyed form. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationHostQuerySupport +} from './automation-host-catalog-types' +import type { AutomationHostRequestFence } from './automation-host-cache-types' +import { + automationHostDiagnostics, + measureAutomationHostResponseChars, + type AutomationHostDiagnostics, + type AutomationHostRequestTransport +} from './automation-host-diagnostics' + +export type AutomationHostRequestInstrumentation = { + /** Wall clock for request duration; kept apart from the scheduler's fake-time `now`. */ + startedAt: () => number + /** Called where the request is sent, not where it is queued: a cancelled job never counts. */ + request: (fence: AutomationHostRequestFence, transport: AutomationHostRequestTransport) => void + dedupeHit: (ref: StableAutomationCatalogRef, querySupport: AutomationHostQuerySupport) => void + response: ( + fence: AutomationHostRequestFence, + stableKey: string | null, + startedAt: number, + rows: number, + payload: unknown + ) => void + /** A request that threw still spent the time; the slowest refreshes are usually these. */ + failed: (fence: AutomationHostRequestFence, stableKey: string | null, startedAt: number) => void + /** One entry's share of a legacy answer, so per-host row counts survive the fan-out. */ + entryRows: (stableKey: string, rows: number) => void + entryDiscarded: (fence: AutomationHostRequestFence) => void + requestDiscarded: ( + fence: AutomationHostRequestFence, + stableKey: string | null, + outcome: 'commit' | 'failure' + ) => void +} + +export function createAutomationHostRequestInstrumentation( + diagnostics: AutomationHostDiagnostics = automationHostDiagnostics, + elapsed: () => number = () => performance.now() +): AutomationHostRequestInstrumentation { + return { + startedAt: elapsed, + request: (fence, transport) => { + diagnostics.recordRequest({ + authorityKey: fence.authorityKey, + stableKey: transport === 'legacy' ? null : fence.stableKey, + transport + }) + }, + // Keyed exactly as the request it joined, or per-key invariants over the two + // could not be written: a legacy host would read 0 requests against N hits. + dedupeHit: (ref, querySupport) => { + diagnostics.recordDedupeHit({ + authorityKey: automationAuthorityCatalogKey(ref.authority), + stableKey: querySupport === 'legacy-unscoped' ? null : hostStableKey(ref) + }) + }, + // Recorded before the fence decides: the payload crossed the wire either + // way, and the size/duration budgets are about what was spent, not kept. + response: (fence, stableKey, startedAt, rows, payload) => { + diagnostics.recordResponse({ + authorityKey: fence.authorityKey, + stableKey, + rows, + durationMs: elapsed() - startedAt, + approxSerializedChars: diagnostics.measuringSerializedChars() + ? measureAutomationHostResponseChars(payload) + : undefined + }) + }, + failed: (fence, stableKey, startedAt) => { + diagnostics.recordFailedRequest({ + authorityKey: fence.authorityKey, + stableKey, + durationMs: elapsed() - startedAt + }) + }, + entryRows: (stableKey, rows) => { + diagnostics.recordEntryRows({ stableKey, rows }) + }, + entryDiscarded: (fence) => { + diagnostics.recordDiscardedEntry({ + authorityKey: fence.authorityKey, + stableKey: fence.stableKey + }) + }, + requestDiscarded: (fence, stableKey, outcome) => { + diagnostics.recordDiscardedRequest({ + authorityKey: fence.authorityKey, + stableKey, + outcome + }) + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-retry-schedule.ts b/src/renderer/src/components/automations/automation-host-retry-schedule.ts new file mode 100644 index 00000000000..18e37a152e7 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-retry-schedule.ts @@ -0,0 +1,75 @@ +/** + * What happens to an entry after its request fails: how the error is classified, + * whether another attempt is allowed, and when it fires. + * + * Separate from the scheduler because these timers outlive the request that + * armed them — a disposed controller has to cancel them, and a manual retry has + * to cancel the pending one rather than race it. + */ + +import type { AutomationHostCache } from './automation-host-cache' +import { classifyAutomationHostQueryError } from './automation-host-cache-error' +import type { PlannedAutomationHostTarget } from './automation-host-scheduler-plan' + +export type AutomationHostRetrySchedule = { + /** False when the fence rejected the failure, which is a discard like any other. */ + record: (target: PlannedAutomationHostTarget, error: unknown) => boolean + cancel: (stableKey: string) => void + dispose: () => void +} + +export type AutomationHostRetryScheduleOptions = { + cache: AutomationHostCache + now: () => number + random?: () => number + /** A hidden window records the failure but arms nothing; the next visit re-plans it. */ + isVisible: () => boolean + scheduleRetry: (run: () => void, delayMs: number) => () => void + /** Starts the next attempt; the scheduler owns how that request is sent. */ + retry: (target: PlannedAutomationHostTarget) => void +} + +export function createAutomationHostRetrySchedule( + options: AutomationHostRetryScheduleOptions +): AutomationHostRetrySchedule { + const timers = new Map void>() + + const cancel = (stableKey: string): void => { + timers.get(stableKey)?.() + timers.delete(stableKey) + } + + return { + cancel, + record: (target, error) => { + const attempt = (options.cache.getByKey(target.stableKey)?.attempt ?? 0) + 1 + const queryError = classifyAutomationHostQueryError(error, { + attempt, + now: options.now(), + random: options.random + }) + if (!options.cache.fail(target.fence, queryError)) { + return false + } + if (!queryError.retryable || !options.isVisible()) { + return true + } + const delay = Math.max(0, (queryError.retryAt ?? options.now()) - options.now()) + cancel(target.stableKey) + timers.set( + target.stableKey, + options.scheduleRetry(() => { + timers.delete(target.stableKey) + options.retry(target) + }, delay) + ) + return true + }, + dispose: () => { + for (const stop of timers.values()) { + stop() + } + timers.clear() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-scale-gates.test.ts b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts new file mode 100644 index 00000000000..e85ed6d1ae5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts @@ -0,0 +1,467 @@ +/** + * The design doc's release gates at the scale it states them: 1,000 automations + * across 50 hosts, driven through the real list client, cache, and scheduler. + * + * Every mechanism these gates rest on already has a unit test proving it works + * in isolation. What only a fixture this size can show is that they *compose* — + * that the four-slot pool still holds when each job also makes an unpooled + * capability probe, that the one-call-per-authority budget survives a fan-out + * over five entries, and that the commit fence still rejects everything stale + * when the rejections happen under real concurrency rather than one at a time. + * + * The wire is mocked at the RPC boundary and nowhere above it, so response + * validation, row projection, and legacy partitioning are all real work here. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import { + automationHostDiagnostics, + type AutomationHostDiagnosticsSnapshot +} from './automation-host-diagnostics' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget +} from './automation-host-scheduler' +import { AUTOMATION_HOST_REQUEST_CONCURRENCY } from './automation-host-scheduler-queue' + +const callRuntimeRpc = vi.fn() +const getRuntimeEnvironmentStatus = vi.fn() + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => getRuntimeEnvironmentStatus(...args), + hasRuntimeRpcErrorCode: () => false +})) + +// 10 authorities x 5 entries = 50 hosts; 50 hosts x 20 = 1,000 automations. +// Half the authorities are old servers, because the legacy fan-out is where the +// per-authority call budget is easiest to lose. +const AUTHORITY_COUNT = 10 +const ENTRIES_PER_AUTHORITY = 5 +const AUTOMATIONS_PER_HOST = 20 +const SCOPED_AUTHORITIES = AUTHORITY_COUNT / 2 +const HOST_COUNT = AUTHORITY_COUNT * ENTRIES_PER_AUTHORITY +const AUTOMATION_COUNT = HOST_COUNT * AUTOMATIONS_PER_HOST +const SSH_TARGET_GENERATION = 3 +// Doc 417: a refresh at this size must not block the renderer for a frame budget's worth of work. +const LONG_TASK_BUDGET_MS = 50 + +function authorityAt(index: number): AutomationAuthorityRef { + return { kind: 'runtime', environmentId: `env-${index}`, pairingRevision: index + 1 } +} + +function stableAuthorityAt(index: number): StableAutomationAuthorityRef { + return { kind: 'runtime', environmentId: `env-${index}` } +} + +function isLegacyAuthority(index: number): boolean { + return index >= SCOPED_AUTHORITIES +} + +function environmentIndex(environmentId: string): number { + return Number(environmentId.slice('env-'.length)) +} + +function sshTargetId(authorityIndex: number, slot: number): string { + return `ssh-${authorityIndex}-${slot}` +} + +// Every authority carries all three selector kinds, because each takes a +// different route: orphan scopes carry no owner, and the legacy partition has to +// separate them from Self without any help from the server. +function refsForAuthority(authorityIndex: number): StableAutomationCatalogRef[] { + const authority = stableAuthorityAt(authorityIndex) + return [ + { authority, selector: { kind: 'self' } }, + { authority, selector: { kind: 'orphan' } }, + ...Array.from({ length: ENTRIES_PER_AUTHORITY - 2 }, (_unused, slot) => ({ + authority, + selector: { kind: 'ssh' as const, targetId: sshTargetId(authorityIndex, slot) } + })) + ] +} + +/** The host label an automation's id carries, so a misrouted row names its real owner. */ +function hostLabel(ref: StableAutomationCatalogRef): string { + return ref.selector.kind === 'ssh' ? ref.selector.targetId : ref.selector.kind +} + +function refForSelector( + index: number, + selector: AutomationListScopeSelector +): StableAutomationCatalogRef { + const authority = stableAuthorityAt(index) + if (selector.kind === 'ssh') { + return { authority, selector: { kind: 'ssh', targetId: selector.targetId } } + } + return selector.kind === 'orphan' + ? { authority, selector: { kind: 'orphan' } } + : { authority, selector: { kind: 'self' } } +} + +const ALL_REFS: StableAutomationCatalogRef[] = Array.from( + { length: AUTHORITY_COUNT }, + (_unused, index) => refsForAuthority(index) +).flat() + +function targetFor(ref: StableAutomationCatalogRef): AutomationHostFetchTarget { + const index = environmentIndex( + ref.authority.kind === 'runtime' ? ref.authority.environmentId : 'env-0' + ) + const authority = authorityAt(index) + return { + ref, + authority, + // An orphan entry has no executable owner, which is the whole reason it is + // one; the scheduler still scopes the request by the selector alone. + owner: + ref.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: SSH_TARGET_GENERATION + } + } + : ref.selector.kind === 'orphan' + ? null + : { authority, selector: { kind: 'self' } }, + querySupport: isLegacyAuthority(index) ? 'legacy-unscoped' : 'scoped' + } +} + +/** Encodes its host in the id, so a misattributed row is visible rather than merely counted. */ +function automationsForHost(ref: StableAutomationCatalogRef, index: number): Automation[] { + return Array.from({ length: AUTOMATIONS_PER_HOST }, (_unused, n) => { + const base = { + id: `auto-${index}-${hostLabel(ref)}-${n}`, + name: `automation ${n}`, + projectId: 'repo-1' + } + if (ref.selector.kind === 'ssh') { + return { + ...base, + executionTargetType: 'ssh', + executionTargetId: ref.selector.targetId + } as Automation + } + // What an old server's orphan looks like on the wire: a record it admits is + // scheduled somewhere it cannot speak for. + return ( + ref.selector.kind === 'orphan' + ? { ...base, executionTargetType: 'local', schedulerOwner: 'remote_host_service' } + : { ...base, executionTargetType: 'local' } + ) as Automation + }) +} + +function respondingSelector(selector: AutomationListScopeSelector): unknown { + if (selector.kind === 'ssh') { + return { + kind: 'ssh', + targetId: selector.targetId, + targetGeneration: selector.expectedTargetGeneration + } + } + return selector.kind === 'orphan' ? { kind: 'orphan', issue: '' } : { kind: 'self' } +} + +function scopedPayload(environmentId: string, selector: AutomationListScopeSelector): unknown { + const index = environmentIndex(environmentId) + const automations = automationsForHost(refForSelector(index, selector), index) + return { + automations, + // Echoing the requested selector is what a correct host does; the client + // rejects anything else, so this keeps the validation path honest. + items: automations.map((entry) => ({ + automationId: entry.id, + selector: respondingSelector(selector) + })), + orphanCount: selector.kind === 'orphan' ? AUTOMATIONS_PER_HOST : 0 + } +} + +/** One old server's whole authority in a single answer, exactly as it would arrive. */ +function legacyPayload(environmentId: string): unknown { + const index = environmentIndex(environmentId) + return { + automations: refsForAuthority(index).flatMap((ref) => automationsForHost(ref, index)) + } +} + +type WireActivity = { + inFlight: number + maxInFlight: number + scopedCalls: number + probes: number + legacyCallsByEnvironment: Map +} + +let wire: WireActivity +let onWireCall: ((environmentId: string) => void) | null + +function beginWireCall(environmentId: string): void { + wire.inFlight += 1 + wire.maxInFlight = Math.max(wire.maxInFlight, wire.inFlight) + onWireCall?.(environmentId) +} + +/** A relay round trip yields; without that nothing overlaps and the ceiling is trivially met. */ +async function roundTrip(environmentId: string, value: () => T): Promise { + beginWireCall(environmentId) + try { + await new Promise((resolve) => setTimeout(resolve, 0)) + return value() + } finally { + wire.inFlight -= 1 + } +} + +/** + * Longest gap between successive macrotasks: a synchronous block anywhere in the + * refresh delays the next timer by its own duration, which is the only way to + * observe "no long task" rather than to assert it about one function. + */ +function watchLongTasks(): () => number { + let worst = 0 + let last = performance.now() + let running = true + const tick = (): void => { + if (!running) { + return + } + const at = performance.now() + worst = Math.max(worst, at - last) + last = at + setTimeout(tick, 0) + } + setTimeout(tick, 0) + return () => { + running = false + return worst + } +} + +function harness(): { cache: AutomationHostCache; refresh: () => Promise } { + const cache = createAutomationHostCache({ + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => ({ repoConnectionId: () => null, projectsAuthoritative: true }), + isVisible: () => true, + scheduleRetry: () => () => {} + }) + return { cache, refresh: () => scheduler.refresh(ALL_REFS.map(targetFor)) } +} + +function snapshot(): AutomationHostDiagnosticsSnapshot { + return automationHostDiagnostics.snapshot() +} + +function authorityCounters(snap: AutomationHostDiagnosticsSnapshot, index: number) { + return snap.byAuthority[automationAuthorityCatalogKey(stableAuthorityAt(index))] +} + +beforeEach(() => { + automationHostDiagnostics.reset() + wire = { + inFlight: 0, + maxInFlight: 0, + scopedCalls: 0, + probes: 0, + legacyCallsByEnvironment: new Map() + } + onWireCall = null + callRuntimeRpc.mockReset() + getRuntimeEnvironmentStatus.mockReset() + getRuntimeEnvironmentStatus.mockImplementation((environmentId: string) => { + wire.probes += 1 + return roundTrip(environmentId, () => ({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + })) + }) + callRuntimeRpc.mockImplementation((target: unknown, _method: unknown, params: unknown) => { + const environmentId = (target as { environmentId: string }).environmentId + if (params === null) { + wire.legacyCallsByEnvironment.set( + environmentId, + (wire.legacyCallsByEnvironment.get(environmentId) ?? 0) + 1 + ) + return roundTrip(environmentId, () => legacyPayload(environmentId)) + } + wire.scopedCalls += 1 + const selector = (params as { selector: AutomationListScopeSelector }).selector + return roundTrip(environmentId, () => scopedPayload(environmentId, selector)) + }) +}) + +describe('one refresh of 50 hosts carrying 1,000 automations', () => { + it('never exceeds four requests in flight, however many hosts are waiting', async () => { + const { refresh } = harness() + + await refresh() + + expect(wire.maxInFlight).toBeGreaterThan(1) + expect(wire.maxInFlight).toBeLessThanOrEqual(AUTOMATION_HOST_REQUEST_CONCURRENCY) + }) + + // The gate a fan-out is most likely to break: five entries on one old server + // must still cost that server one call, not five. + it('asks each old server exactly once, and each modern host exactly once', async () => { + const { refresh } = harness() + + await refresh() + + const snap = snapshot() + expect(wire.scopedCalls).toBe(SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY) + expect([...wire.legacyCallsByEnvironment.values()]).toEqual( + Array.from({ length: AUTHORITY_COUNT - SCOPED_AUTHORITIES }, () => 1) + ) + expect(snap.totals).toMatchObject({ + scopedRequests: SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY, + legacyRequests: AUTHORITY_COUNT - SCOPED_AUTHORITIES + }) + for (let index = SCOPED_AUTHORITIES; index < AUTHORITY_COUNT; index += 1) { + expect(authorityCounters(snap, index).legacyRequests).toBe(1) + } + }) + + it('lands all 1,000 automations on the host each one belongs to', async () => { + const { cache, refresh } = harness() + + await refresh() + + let landed = 0 + for (const ref of ALL_REFS) { + const rows = cache.get(ref)?.data ?? [] + const index = environmentIndex( + ref.authority.kind === 'runtime' ? ref.authority.environmentId : 'env-0' + ) + const prefix = `auto-${index}-${hostLabel(ref)}-` + expect(rows).toHaveLength(AUTOMATIONS_PER_HOST) + // A row carrying another host's prefix is a misattribution, not a miscount. + expect(rows.every((row) => row.automation.id.startsWith(prefix))).toBe(true) + landed += rows.length + } + expect(landed).toBe(AUTOMATION_COUNT) + }) + + // Per-host row counts have to survive the legacy fan-out, or doc 416's numbers + // go missing for exactly the hosts whose payload is worst. + it('accounts for every row twice over: once per response and once per host', async () => { + const { refresh } = harness() + + await refresh() + + const snap = snapshot() + const perHost = Object.values(snap.byStableKey).reduce((sum, entry) => sum + entry.rows, 0) + expect(snap.totals.rows).toBe(AUTOMATION_COUNT) + expect(perHost).toBe(AUTOMATION_COUNT) + }) + + it('does no synchronous work long enough to drop a frame', async () => { + const { refresh } = harness() + const stop = watchLongTasks() + + await refresh() + + expect(stop()).toBeLessThan(LONG_TASK_BUDGET_MS) + }) +}) + +describe('relay cost the request pool cannot see', () => { + // The probe is deliberate and unpooled, so the honest number for a 50-host + // refresh is requests plus probes — not the pooled count on its own. + it('counts one capability probe for every scoped list, and none for a legacy one', async () => { + const { refresh } = harness() + + await refresh() + + const snap = snapshot() + const scopedEntries = SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY + expect(wire.probes).toBe(scopedEntries) + expect(snap.totals.capabilityProbes).toBe(scopedEntries) + for (let index = 0; index < AUTHORITY_COUNT; index += 1) { + expect(authorityCounters(snap, index).capabilityProbes).toBe( + isLegacyAuthority(index) ? 0 : ENTRIES_PER_AUTHORITY + ) + } + // Scoped hosts cost two round trips each, so the pooled count alone reports + // barely half the traffic this refresh actually put on the relay. + expect(snap.totals.requests + snap.totals.capabilityProbes).toBe( + scopedEntries * 2 + (AUTHORITY_COUNT - SCOPED_AUTHORITIES) + ) + }) +}) + +describe('the commit fence under real concurrency', () => { + it('commits nothing from an authority that re-paired while its answer was in flight', async () => { + const { cache, refresh } = harness() + // One modern and one old server, so both the per-entry and the fan-out arm + // are fenced out while the other 40 hosts keep landing around them. + const stale = new Set(['env-0', `env-${AUTHORITY_COUNT - 1}`]) + const invalidated = new Set() + onWireCall = (environmentId) => { + if (!stale.has(environmentId) || invalidated.has(environmentId)) { + return + } + invalidated.add(environmentId) + cache.invalidateAuthority(stableAuthorityAt(environmentIndex(environmentId))) + } + + await refresh() + + const snap = snapshot() + for (const environmentId of stale) { + const index = environmentIndex(environmentId) + for (const ref of refsForAuthority(index)) { + expect(cache.get(ref)?.data ?? []).toEqual([]) + } + // The answer arrived and was thrown away, which is the gate's whole point. + expect(authorityCounters(snap, index).discardedCommits).toBeGreaterThanOrEqual(1) + } + // Everything else still landed: a fence that rejected the innocent would + // satisfy the stale-response gate and lose the user's rows doing it. + const survivors = ALL_REFS.filter( + (ref) => !stale.has(ref.authority.kind === 'runtime' ? ref.authority.environmentId : '') + ) + expect(survivors).toHaveLength(HOST_COUNT - stale.size * ENTRIES_PER_AUTHORITY) + for (const ref of survivors) { + expect(cache.get(ref)?.data ?? []).toHaveLength(AUTOMATIONS_PER_HOST) + } + }) + + it('leaves no stale row anywhere in the cache, not merely on its own host', async () => { + const { cache, refresh } = harness() + const invalidated = new Set() + onWireCall = (environmentId) => { + if (environmentId !== 'env-0' || invalidated.has(environmentId)) { + return + } + invalidated.add(environmentId) + cache.invalidateAuthority(stableAuthorityAt(0)) + } + + await refresh() + + for (const ref of ALL_REFS) { + const rows = cache.get(ref)?.data ?? [] + expect(rows.some((row) => row.automation.id.startsWith('auto-0-'))).toBe(false) + } + expect(snapshot().byStableKey[hostStableKey(ALL_REFS[0])].rows).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-scheduler-plan.ts b/src/renderer/src/components/automations/automation-host-scheduler-plan.ts new file mode 100644 index 00000000000..b9e2c17f8bd --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler-plan.ts @@ -0,0 +1,68 @@ +/** + * Decides which of the requested entries actually get fetched this cycle, and + * captures the fence each one will have to commit under. + * + * Everything that makes a request *not* happen lives here: a duplicate ref, a + * still-fresh entry, one already in flight, and one inside its retry cooldown. + * The scheduler then only has to decide how to send what survives. + */ + +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRequestFence } from './automation-host-cache-types' +// Type-only, so it is erased before it could become an import cycle at runtime. +import type { AutomationHostFetchTarget } from './automation-host-scheduler' + +export type PlannedAutomationHostTarget = AutomationHostFetchTarget & { + stableKey: string + fence: AutomationHostRequestFence +} + +export type AutomationHostRequestPlanOptions = { + cache: AutomationHostCache + targets: readonly AutomationHostFetchTarget[] + /** Skips freshness and cooldown, and supersedes anything already in flight. */ + force: boolean + /** Manual All-host refresh: it bypasses TTL but never re-enqueues a known failure. */ + skipKnownFailures?: boolean + now: () => number +} + +export function planAutomationHostRequests({ + cache, + targets, + force, + skipKnownFailures = false, + now +}: AutomationHostRequestPlanOptions): PlannedAutomationHostTarget[] { + const planned = new Map() + for (const target of targets) { + const stableKey = hostStableKey(target.ref) + const existing = planned.get(stableKey) + if (existing) { + existing.priority = existing.priority === true || target.priority === true + continue + } + const entry = cache.getByKey(stableKey) + if (force) { + if (skipKnownFailures && entry?.error && !entry.error.retryable) { + continue + } + // Why: a replacement request must outrank the one it replaces, or the older + // answer lands last and overwrites the fresher rows it was meant to supersede. + if (entry?.request) { + cache.supersede(target.ref) + } + } else { + if (cache.freshness(target.ref) === 'fresh' || entry?.request) { + continue + } + const error = entry?.error + if (error && (!error.retryable || (error.retryAt !== null && error.retryAt > now()))) { + continue + } + } + planned.set(stableKey, { ...target, stableKey, fence: cache.beginRequest(target.ref) }) + } + return [...planned.values()] +} diff --git a/src/renderer/src/components/automations/automation-host-scheduler-queue.ts b/src/renderer/src/components/automations/automation-host-scheduler-queue.ts new file mode 100644 index 00000000000..41e4161b726 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler-queue.ts @@ -0,0 +1,102 @@ +/** + * The four-slot request pool every authority call goes through. + * + * Priority work (the selected host, and Desktop + Self) jumps the queue, and + * queued work that the catalog has made obsolete is dropped before it is sent — + * transport already in flight is left to finish and rejected by the commit fence + * instead, because cancelling it would not un-send it. + */ + +export const AUTOMATION_HOST_REQUEST_CONCURRENCY = 4 + +export type AutomationHostPoolJob = { + priority?: boolean + run: () => Promise + /** Runs instead of `run` when the job is dropped before it starts. */ + cancel?: () => void +} + +export type AutomationHostRequestPool = { + submit: (job: AutomationHostPoolJob) => Promise + cancelQueued: () => void + inFlight: () => number + queued: () => number + /** Resolves once nothing is queued or in flight. */ + idle: () => Promise +} + +type WaitingJob = { + job: AutomationHostPoolJob + settle: () => void +} + +export function createAutomationHostRequestPool( + concurrency: number = AUTOMATION_HOST_REQUEST_CONCURRENCY +): AutomationHostRequestPool { + const urgent: WaitingJob[] = [] + const waiting: WaitingJob[] = [] + const idleWaiters: (() => void)[] = [] + let active = 0 + + const pending = (): number => urgent.length + waiting.length + + const releaseIdle = (): void => { + if (active > 0 || pending() > 0) { + return + } + for (const waiter of idleWaiters.splice(0)) { + waiter() + } + } + + const pump = (): void => { + while (active < concurrency && pending() > 0) { + const next = urgent.shift() ?? waiting.shift() + if (!next) { + break + } + active += 1 + void next.job.run().then( + () => { + active -= 1 + next.settle() + pump() + releaseIdle() + }, + () => { + // Jobs own their error handling; the pool only tracks occupancy. + active -= 1 + next.settle() + pump() + releaseIdle() + } + ) + } + releaseIdle() + } + + return { + submit: (job) => + new Promise((resolve) => { + ;(job.priority === true ? urgent : waiting).push({ job, settle: resolve }) + pump() + }), + cancelQueued: () => { + for (const entry of [...urgent.splice(0), ...waiting.splice(0)]) { + // Settling alone resolves the submitter without ever running the job, so a + // dropped job must first hand back whatever it reserved on submission. + entry.job.cancel?.() + entry.settle() + } + releaseIdle() + }, + inFlight: () => active, + queued: () => pending(), + idle: () => + active === 0 && pending() === 0 + ? Promise.resolve() + : new Promise((resolve) => { + idleWaiters.push(resolve) + }) + } +} diff --git a/src/renderer/src/components/automations/automation-host-scheduler.test.ts b/src/renderer/src/components/automations/automation-host-scheduler.test.ts new file mode 100644 index 00000000000..7942eb49fe8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler.test.ts @@ -0,0 +1,561 @@ +/** + * The scheduler's budget is the contract: four calls in flight, one unscoped + * call per old authority per cycle, and no run-history fetch to paper over the + * usage an old response cannot carry. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { createAutomationHostCache, type AutomationHostCache } from './automation-host-cache' +import type { AutomationHostQuerySupport } from './automation-host-catalog-types' +import { + createAutomationHostScheduler, + type AutomationHostFetchTarget +} from './automation-host-scheduler' +import type { ScopedAutomationList } from './automation-scoped-list-client' + +const RUNTIME_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-1', + pairingRevision: 4 +} +const OTHER_AUTHORITY: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-2', + pairingRevision: 1 +} + +function stableAuthority(authority: AutomationAuthorityRef): StableAutomationAuthorityRef { + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: authority.environmentId } +} + +function selfRef(authority: AutomationAuthorityRef): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'self' } } +} + +function sshRef(authority: AutomationAuthorityRef, targetId: string): StableAutomationCatalogRef { + return { authority: stableAuthority(authority), selector: { kind: 'ssh', targetId } } +} + +function target( + ref: StableAutomationCatalogRef, + options: { + authority?: AutomationAuthorityRef + querySupport?: AutomationHostQuerySupport + targetGeneration?: number + priority?: boolean + } = {} +): AutomationHostFetchTarget { + const authority = options.authority ?? RUNTIME_AUTHORITY + return { + ref, + authority, + owner: + ref.selector.kind === 'ssh' + ? { + authority, + selector: { + kind: 'ssh', + targetId: ref.selector.targetId, + targetGeneration: options.targetGeneration ?? 2 + } + } + : { authority, selector: { kind: 'self' } }, + querySupport: options.querySupport ?? 'scoped', + priority: options.priority + } +} + +function automation(id: string, overrides: Partial = {}): Automation { + return { + id, + name: id, + projectId: 'repo-1', + executionTargetType: 'local', + ...overrides + } as Automation +} + +function scopedResult(ids: readonly string[]): ScopedAutomationList { + return { + automations: ids.map((id) => automation(id)), + items: ids.map((id) => ({ automationId: id, selector: { kind: 'self' as const } })), + orphanCount: 0, + invalidRows: 0 + } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function createCache(now?: () => number): AutomationHostCache { + return createAutomationHostCache({ + now, + catalogGeneration: () => 0, + connectionGeneration: () => 0 + }) +} + +const LOCAL_REPO = { repoConnectionId: () => null, projectsAuthoritative: true } + +describe('automation host scheduler concurrency', () => { + it('keeps at most four authority calls in flight', async () => { + const cache = createCache() + const gates = Array.from({ length: 8 }, () => deferred()) + let started = 0 + const listScoped = vi.fn(() => gates[started++].promise) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + + const targets = Array.from({ length: 8 }, (_, index) => + target(sshRef(RUNTIME_AUTHORITY, `target-${index}`)) + ) + const done = scheduler.refresh(targets) + await Promise.resolve() + expect(scheduler.inFlight()).toBe(4) + expect(listScoped).toHaveBeenCalledTimes(4) + + for (const gate of gates) { + gate.resolve(scopedResult([])) + await Promise.resolve() + } + await done + expect(listScoped).toHaveBeenCalledTimes(8) + }) + + it('runs the selected host before the rest of the queue', async () => { + const cache = createCache() + const order: string[] = [] + const listScoped = vi.fn((_authority, selector: AutomationListScopeSelector) => { + order.push(selector.kind === 'ssh' ? selector.targetId : selector.kind) + return Promise.resolve(scopedResult([])) + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped } + }) + await scheduler.refresh([ + target(sshRef(RUNTIME_AUTHORITY, 'a')), + target(sshRef(RUNTIME_AUTHORITY, 'b')), + target(sshRef(RUNTIME_AUTHORITY, 'selected'), { priority: true }) + ]) + expect(order[0]).toBe('a') + expect(order[1]).toBe('selected') + }) + + it('shares one in-flight request between concurrent callers for the same owner', async () => { + const cache = createCache() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + const first = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + await Promise.resolve() + const second = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + gate.resolve(scopedResult(['a'])) + await Promise.all([first, second]) + expect(listScoped).toHaveBeenCalledTimes(1) + }) + + // Ordinary startup: the catalog settles a moment after the first refresh, so the + // re-apply cancels a queue full of hosts nothing else will ever ask for again. + it('re-fetches queued work that was cancelled before it was sent', async () => { + const cache = createCache() + const gates: { resolve: (value: ScopedAutomationList) => void }[] = [] + const queried: string[] = [] + let blocking = true + const listScoped = vi.fn((_authority: AutomationAuthorityRef, selector) => { + queried.push(selector.kind === 'ssh' ? selector.targetId : selector.kind) + if (!blocking) { + return Promise.resolve(scopedResult([])) + } + const gate = deferred() + gates.push(gate) + return gate.promise + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 2, + transport: { listScoped } + }) + + const targets = Array.from({ length: 5 }, (_, index) => + target(sshRef(RUNTIME_AUTHORITY, `target-${index}`)) + ) + const first = scheduler.refresh(targets) + await Promise.resolve() + expect(queried).toEqual(['target-0', 'target-1']) + + scheduler.cancelQueued() + for (const gate of gates) { + gate.resolve(scopedResult([])) + } + await first + + blocking = false + await scheduler.refresh(targets) + expect(queried).toEqual(['target-0', 'target-1', 'target-2', 'target-3', 'target-4']) + }) + + it('clears the request marker of a queued job the pool dropped', async () => { + const cache = createCache() + const gate = deferred() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped: () => gate.promise } + }) + const queued = target(sshRef(RUNTIME_AUTHORITY, 'queued')) + const done = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), queued]) + await Promise.resolve() + + scheduler.cancelQueued() + expect(cache.get(queued.ref)?.request).toBeNull() + gate.resolve(scopedResult([])) + await done + }) + + // One legacy job answers for a whole authority, so cancelling it strands every + // entry in the group, not just the one that happened to submit it. + it('clears every marker of a cancelled legacy authority job', async () => { + const cache = createCache() + const gate = deferred() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped: () => gate.promise, listLegacy: () => Promise.resolve([]) } + }) + const legacy = { querySupport: 'legacy-unscoped' as const, authority: OTHER_AUTHORITY } + const group = [ + target(selfRef(OTHER_AUTHORITY), legacy), + target(sshRef(OTHER_AUTHORITY, 'target-1'), legacy) + ] + const done = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), ...group]) + await Promise.resolve() + + scheduler.cancelQueued() + expect(group.map((entry) => cache.get(entry.ref)?.request)).toEqual([null, null]) + gate.resolve(scopedResult([])) + await done + }) + + it('clears the marker of a target it has no way to address', async () => { + const cache = createCache() + const listScoped = vi.fn() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + // A ghost SSH entry: no registration generation, so no selector can be fenced. + const ghost = { ...target(sshRef(RUNTIME_AUTHORITY, 'ghost')), owner: null } + await scheduler.refresh([ghost]) + + expect(listScoped).not.toHaveBeenCalled() + expect(cache.get(ghost.ref)?.request).toBeNull() + }) + + it('drops queued work whose entry was invalidated before it was sent', async () => { + const cache = createCache() + const gate = deferred() + const listScoped = vi.fn(() => gate.promise) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + concurrency: 1, + transport: { listScoped } + }) + const queued = target(sshRef(RUNTIME_AUTHORITY, 'queued')) + const done = scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY)), queued]) + await Promise.resolve() + cache.invalidate(queued.ref) + gate.resolve(scopedResult([])) + await done + expect(listScoped).toHaveBeenCalledTimes(1) + }) +}) + +describe('automation host scheduler legacy path', () => { + it('makes one unscoped call per authority per cycle and partitions it', async () => { + const cache = createCache() + const listRuns = vi.fn() + vi.stubGlobal('window', { api: { automations: { listRuns } } }) + const listLegacy = vi.fn((authority: AutomationAuthorityRef) => + Promise.resolve( + authority.kind === 'runtime' && authority.environmentId === 'env-1' + ? [ + automation('self-1'), + automation('ssh-1', { + executionTargetType: 'ssh', + executionTargetId: 'target-1' + }), + automation('broken', { executionTargetType: 'ssh' }) + ] + : [automation('other-self')] + ) + ) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listLegacy } + }) + + const legacy = { querySupport: 'legacy-unscoped' as const } + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), legacy), + target(sshRef(RUNTIME_AUTHORITY, 'target-1'), legacy), + target( + { authority: stableAuthority(RUNTIME_AUTHORITY), selector: { kind: 'orphan' } }, + legacy + ), + target(selfRef(OTHER_AUTHORITY), { ...legacy, authority: OTHER_AUTHORITY }) + ]) + + expect(listLegacy).toHaveBeenCalledTimes(2) + const self = cache.get(selfRef(RUNTIME_AUTHORITY)) + expect(self?.data.map((row) => row.automation.id)).toEqual(['self-1']) + expect( + cache.get(sshRef(RUNTIME_AUTHORITY, 'target-1'))?.data.map((row) => row.automation.id) + ).toEqual(['ssh-1']) + const orphanEntry = cache.getByKey( + hostStableKey({ authority: stableAuthority(RUNTIME_AUTHORITY), selector: { kind: 'orphan' } }) + ) + expect(orphanEntry?.data.map((row) => row.automation.id)).toEqual(['broken']) + expect(orphanEntry?.orphanCount).toBe(1) + vi.unstubAllGlobals() + expect(listRuns).not.toHaveBeenCalled() + }) + + it('leaves legacy rows unfenced and their usage unavailable', async () => { + const cache = createCache() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listLegacy: () => Promise.resolve([automation('a')]) } + }) + await scheduler.refresh([ + target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'legacy-unscoped' }) + ]) + const row = cache.get(selfRef(RUNTIME_AUTHORITY))?.data[0] + expect(row?.owner).toBeNull() + expect(row?.usageKnown).toBe(false) + expect(row?.usageSummary).toBeNull() + }) + + it('records an incompatible host without sending anything', async () => { + const cache = createCache() + const listScoped = vi.fn() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY), { querySupport: 'incompatible' })]) + expect(listScoped).not.toHaveBeenCalled() + expect(cache.get(selfRef(RUNTIME_AUTHORITY))?.error).toMatchObject({ + code: 'incompatible', + retryable: false + }) + }) +}) + +describe('automation host scheduler revalidation', () => { + it('skips a fresh entry and refetches once it is stale or forced', async () => { + let clock = 1_000 + const cache = createCache(() => clock) + const listScoped = vi.fn(() => Promise.resolve(scopedResult([]))) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + now: () => clock, + transport: { listScoped } + }) + const only = [target(selfRef(RUNTIME_AUTHORITY))] + await scheduler.refresh(only) + await scheduler.refresh(only) + expect(listScoped).toHaveBeenCalledTimes(1) + await scheduler.refresh(only, { force: true }) + expect(listScoped).toHaveBeenCalledTimes(2) + clock += 30_001 + await scheduler.refresh(only) + expect(listScoped).toHaveBeenCalledTimes(3) + }) + + it('retries a transient failure with jittered backoff and stops at the attempt cap', async () => { + let clock = 0 + const cache = createCache(() => clock) + const delays: number[] = [] + const listScoped = vi.fn(() => Promise.reject(new Error('runtime_unavailable'))) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + now: () => clock, + random: () => 1, + transport: { listScoped }, + scheduleRetry: (run, delayMs) => { + delays.push(delayMs) + void Promise.resolve().then(run) + return () => {} + } + }) + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Full jitter with a 1s base: 1s then 2s, and no third retry once the cap is reached. + expect(delays).toEqual([1_000, 2_000]) + expect(listScoped).toHaveBeenCalledTimes(3) + expect(cache.get(selfRef(RUNTIME_AUTHORITY))?.error).toMatchObject({ + code: 'authority_unavailable', + retryable: false + }) + }) + + it('does not schedule automatic retries while the page is hidden', async () => { + const cache = createCache() + const scheduleRetry = vi.fn(() => () => {}) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => false, + transport: { listScoped: () => Promise.reject(new Error('runtime_unavailable')) }, + scheduleRetry + }) + await scheduler.refresh([target(selfRef(RUNTIME_AUTHORITY))]) + expect(scheduleRetry).not.toHaveBeenCalled() + }) + + it('lets a manual retry bypass the cooldown', async () => { + let clock = 0 + const cache = createCache(() => clock) + let calls = 0 + const listScoped = vi.fn(() => { + calls += 1 + return calls === 1 + ? Promise.reject(new Error('runtime_unavailable')) + : Promise.resolve(scopedResult(['a'])) + }) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + now: () => clock, + random: () => 1, + transport: { listScoped }, + scheduleRetry: () => () => {} + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + await scheduler.refresh([only]) + // Still inside the backoff window, so an ordinary refresh is a no-op. + await scheduler.refresh([only]) + expect(listScoped).toHaveBeenCalledTimes(1) + await scheduler.retry(only) + expect(listScoped).toHaveBeenCalledTimes(2) + expect(cache.get(selfRef(RUNTIME_AUTHORITY))?.data).toHaveLength(1) + }) + + // Design doc: a manual All-host refresh bypasses TTL but does not enqueue + // requests already known to fail; that host keeps its own Retry instead. + it('leaves a permanently failed host out of a manual all-hosts refresh', async () => { + const cache = createCache() + let outcome: () => Promise = () => + Promise.reject(new Error('permission_denied')) + const listScoped = vi.fn(() => outcome()) + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped } + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + await scheduler.refresh([only]) + expect(cache.get(only.ref)?.error).toMatchObject({ + code: 'permission_denied', + retryable: false + }) + + await scheduler.refresh([only], { force: true, skipKnownFailures: true }) + expect(listScoped).toHaveBeenCalledTimes(1) + + outcome = () => Promise.resolve(scopedResult(['a'])) + await scheduler.retry(only) + expect(listScoped).toHaveBeenCalledTimes(2) + }) + + it('lets a forced refresh supersede the request it replaces', async () => { + const cache = createCache() + const gates = [deferred(), deferred()] + let started = 0 + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped: () => gates[started++].promise } + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + const first = scheduler.refresh([only]) + await Promise.resolve() + const second = scheduler.refresh([only], { force: true }) + await Promise.resolve() + + gates[1].resolve(scopedResult(['fresh'])) + gates[0].resolve(scopedResult(['stale'])) + await Promise.all([first, second]) + + expect(cache.get(only.ref)?.data.map((row) => row.automation.id)).toEqual(['fresh']) + }) + + it('discards a response whose entry was invalidated while in flight', async () => { + const cache = createCache() + const gate = deferred() + const scheduler = createAutomationHostScheduler({ + cache, + legacyPartitionContext: () => LOCAL_REPO, + isVisible: () => true, + transport: { listScoped: () => gate.promise } + }) + const only = target(selfRef(RUNTIME_AUTHORITY)) + const done = scheduler.refresh([only]) + await Promise.resolve() + cache.invalidate(only.ref) + gate.resolve(scopedResult(['a'])) + await done + expect(cache.get(only.ref)?.data).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-scheduler.ts b/src/renderer/src/components/automations/automation-host-scheduler.ts new file mode 100644 index 00000000000..310e2bd0c8b --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scheduler.ts @@ -0,0 +1,339 @@ +/** + * Decides which host entries to fetch, over which transport, and how often. + * + * Three rules shape everything here: at most four authority calls are in flight + * at once, an old runtime is asked once per cycle and its answer partitioned + * into every entry that wanted it, and a response commits only through the + * cache's fence — so work that was obsolete before it returned is discarded + * rather than rendered. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import type { LegacyAutomationPartitionContext } from '../../../../shared/automation-legacy-list-partition' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import type { + AutomationAuthorityRef, + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import { + automationAuthorityCatalogKey, + type AutomationHostQuerySupport +} from './automation-host-catalog-types' +import type { AutomationHostCache } from './automation-host-cache' +import type { AutomationHostRow } from './automation-host-cache-types' +import { + partitionLegacyAutomationHostRows, + toScopedAutomationHostRows +} from './automation-host-cache-rows' +import { + AutomationHostScopeUnsupportedError, + listLegacyAutomations, + listScopedAutomations, + type ScopedAutomationList +} from './automation-scoped-list-client' +import { + createAutomationHostRequestPool, + type AutomationHostRequestPool +} from './automation-host-scheduler-queue' +import type { AutomationHostDiagnostics } from './automation-host-diagnostics' +import { createAutomationHostRequestInstrumentation } from './automation-host-request-instrumentation' +import { createAutomationHostRetrySchedule } from './automation-host-retry-schedule' +import { + planAutomationHostRequests, + type PlannedAutomationHostTarget +} from './automation-host-scheduler-plan' + +export type AutomationHostFetchTarget = { + ref: StableAutomationCatalogRef + /** Incarnation-bearing authority; its pairing revision pins the request. */ + authority: AutomationAuthorityRef + /** The entry's fenceable owner, or null for a ghost/unhydrated/legacy entry. */ + owner: AutomationOwnerRef | null + querySupport: AutomationHostQuerySupport + /** The selected host and Desktop + Self jump the queue. */ + priority?: boolean +} + +export type AutomationHostSchedulerTransport = { + listScoped: ( + authority: AutomationAuthorityRef, + selector: AutomationListScopeSelector + ) => Promise + listLegacy: (authority: AutomationAuthorityRef) => Promise +} + +export type AutomationHostSchedulerOptions = { + cache: AutomationHostCache + legacyPartitionContext: ( + authority: StableAutomationAuthorityRef + ) => LegacyAutomationPartitionContext + transport?: Partial + concurrency?: number + now?: () => number + random?: () => number + isVisible?: () => boolean + scheduleRetry?: (run: () => void, delayMs: number) => () => void + diagnostics?: AutomationHostDiagnostics + /** Wall clock for request duration; separate from `now`, which fake time drives. */ + elapsed?: () => number +} + +export type AutomationHostRefreshOptions = { + /** Skips freshness and cooldown, and supersedes anything already in flight. */ + force?: boolean + /** Manual All-host refresh: leaves entries whose last error cannot succeed on a retry. */ + skipKnownFailures?: boolean +} + +export type AutomationHostScheduler = { + refresh: ( + targets: readonly AutomationHostFetchTarget[], + options?: AutomationHostRefreshOptions + ) => Promise + /** Manual retry: bypasses the cooldown and starts one new attempt. */ + retry: (target: AutomationHostFetchTarget) => Promise + cancelQueued: () => void + inFlight: () => number + dispose: () => void +} + +type PlannedTarget = PlannedAutomationHostTarget + +function scopeSelectorFor(target: AutomationHostFetchTarget): AutomationListScopeSelector | null { + const selector = target.ref.selector + if (selector.kind === 'self' || selector.kind === 'orphan') { + return { kind: selector.kind } + } + // Why: an SSH scope without a registration generation cannot be fenced, and a + // ghost target's records are returned by the authority's orphan scope anyway. + return target.owner?.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: target.owner.selector.targetId, + expectedTargetGeneration: target.owner.selector.targetGeneration + } + : null +} + +export function createAutomationHostScheduler( + options: AutomationHostSchedulerOptions +): AutomationHostScheduler { + const cache = options.cache + const now = options.now ?? Date.now + const isVisible = options.isVisible ?? (() => document.visibilityState !== 'hidden') + const listScoped = options.transport?.listScoped ?? listScopedAutomations + const listLegacy = options.transport?.listLegacy ?? listLegacyAutomations + const scheduleRetry = + options.scheduleRetry ?? + ((run, delayMs) => { + const handle = setTimeout(run, delayMs) + return () => clearTimeout(handle) + }) + const pool: AutomationHostRequestPool = createAutomationHostRequestPool(options.concurrency) + const instrument = createAutomationHostRequestInstrumentation( + options.diagnostics, + options.elapsed + ) + let disposed = false + + const retries = createAutomationHostRetrySchedule({ + cache, + now, + random: options.random, + isVisible, + scheduleRetry, + retry: (target) => void refresh([target], { force: true }) + }) + + const stillCurrent = (target: PlannedTarget): boolean => + !disposed && + cache.getByKey(target.stableKey)?.requestGeneration === target.fence.requestGeneration + + /** + * Settles one answer against every entry it was fetched for, counting the two + * things the release gate distinguishes: each entry the fence rejected, and — + * only when nothing at all landed — the request whose answer was thrown away. + * A legacy answer that lands for eleven of twelve hosts is not a stale + * response; it is one response and one stale entry. + */ + const settleGroup = ( + live: readonly PlannedTarget[], + stableKey: string | null, + outcome: 'commit' | 'failure', + apply: (target: PlannedTarget) => boolean + ): void => { + let landed = 0 + for (const target of live) { + if (apply(target)) { + landed += 1 + } else { + instrument.entryDiscarded(target.fence) + } + } + if (landed === 0) { + instrument.requestDiscarded(live[0].fence, stableKey, outcome) + } + } + + const runScoped = async (target: PlannedTarget): Promise => { + if (!stillCurrent(target)) { + return + } + const selector = scopeSelectorFor(target) + if (!selector) { + // Nothing will ever answer for this entry, so its marker has to go too. + cache.abandonRequest(target.fence) + return + } + // Counted here rather than at submission: a queued job the catalog dropped + // is never sent, and a request that was never sent must not be in the count. + instrument.request(target.fence, 'scoped') + const startedAt = instrument.startedAt() + try { + const result = await listScoped(target.authority, selector) + const rows = toScopedAutomationHostRows(target.authority, result) + instrument.response(target.fence, target.stableKey, startedAt, rows.length, result) + settleGroup([target], target.stableKey, 'commit', (entry) => + cache.commit(entry.fence, { rows, orphanCount: result.orphanCount }) + ) + } catch (error) { + instrument.failed(target.fence, target.stableKey, startedAt) + settleGroup([target], target.stableKey, 'failure', (entry) => retries.record(entry, error)) + } + } + + const runLegacyAuthority = async (targets: readonly PlannedTarget[]): Promise => { + const live = targets.filter(stillCurrent) + if (live.length === 0) { + return + } + const authority = live[0].authority + instrument.request(live[0].fence, 'legacy') + const startedAt = instrument.startedAt() + try { + const automations = await listLegacy(authority) + // Attributed to the authority alone: one answer serves every entry in the group. + instrument.response(live[0].fence, null, startedAt, automations.length, automations) + const partition = partitionLegacyAutomationHostRows( + automations, + live.map((target) => target.ref), + options.legacyPartitionContext(live[0].ref.authority), + hostStableKey + ) + const rowsFor = (target: PlannedTarget): readonly AutomationHostRow[] => + partition.rowsByStableKey.get(target.stableKey) ?? [] + // The call belongs to the authority, but the rows belong to the hosts — + // without this, per-host row counts are missing exactly where payload is worst. + for (const target of live) { + instrument.entryRows(target.stableKey, rowsFor(target).length) + } + // The count is the whole authority's, so every entry reports it — as the scoped + // path does. Recording it only under an orphan request would need the orphan + // entry that only a reported count creates. + settleGroup(live, null, 'commit', (target) => + cache.commit(target.fence, { rows: rowsFor(target), orphanCount: partition.orphanCount }) + ) + } catch (error) { + instrument.failed(live[0].fence, null, startedAt) + settleGroup(live, null, 'failure', (target) => retries.record(target, error)) + } + } + + /** Hands back the markers of queued work the pool dropped before sending it. */ + const abandon = (targets: readonly PlannedTarget[]): void => { + for (const target of targets) { + cache.abandonRequest(target.fence) + } + } + + const refresh = async ( + targets: readonly AutomationHostFetchTarget[], + refreshOptions: AutomationHostRefreshOptions = {} + ): Promise => { + if (disposed) { + return + } + const force = refreshOptions.force === true + const joined: Promise[] = [] + const counted = new Set() + for (const target of force ? [] : targets) { + // plan() collapses a repeated ref into one entry, so counting the raw array + // would score two hits on the single promise they share. + const stableKey = hostStableKey(target.ref) + if (counted.has(stableKey)) { + continue + } + counted.add(stableKey) + const pending = cache.pendingRequest(target.ref) + if (!pending) { + continue + } + instrument.dedupeHit(target.ref, target.querySupport) + joined.push(pending) + } + const planned = planAutomationHostRequests({ + cache, + targets, + force, + skipKnownFailures: refreshOptions.skipKnownFailures === true, + now + }) + const incompatible = planned.filter((target) => target.querySupport === 'incompatible') + for (const target of incompatible) { + retries.record( + target, + new AutomationHostScopeUnsupportedError( + 'This host is too old to list automations. Update the HUB and try again.' + ) + ) + } + const fetchable = planned.filter((target) => target.querySupport !== 'incompatible') + const legacyByAuthority = new Map() + const submitted: Promise[] = [...joined] + for (const target of fetchable) { + if (target.querySupport === 'legacy-unscoped') { + const key = automationAuthorityCatalogKey(target.ref.authority) + legacyByAuthority.set(key, [...(legacyByAuthority.get(key) ?? []), target]) + continue + } + const request = pool.submit({ + priority: target.priority, + run: () => runScoped(target), + cancel: () => abandon([target]) + }) + cache.trackRequest(target.fence, request) + submitted.push(request) + } + for (const group of legacyByAuthority.values()) { + // One request for the whole authority; every entry in the group shares it. + const request = pool.submit({ + priority: group.some((target) => target.priority === true), + run: () => runLegacyAuthority(group), + cancel: () => abandon(group) + }) + for (const target of group) { + cache.trackRequest(target.fence, request) + } + submitted.push(request) + } + await Promise.all(submitted) + } + + return { + refresh, + retry: async (target) => { + retries.cancel(hostStableKey(target.ref)) + await refresh([{ ...target, priority: true }], { force: true }) + }, + cancelQueued: () => pool.cancelQueued(), + inFlight: () => pool.inFlight(), + dispose: () => { + disposed = true + pool.cancelQueued() + retries.dispose() + } + } +} diff --git a/src/renderer/src/components/automations/automation-host-scope-descriptors.ts b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts new file mode 100644 index 00000000000..1f5fb04e4d1 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-scope-descriptors.ts @@ -0,0 +1,94 @@ +import { translate } from '@/i18n/i18n' +import type { + AutomationHostCatalogEntry, + AutomationHostScopeGap +} from './automation-host-catalog-types' +import type { AutomationHostStatusDescriptor } from './automation-host-status-descriptors' + +/** + * Copy for the query-contract axis. + * + * Every degraded entry shares one consequence — view only — but not one cause. + * A removed target, a target we have not verified since the connection dropped, + * and a server that advertises no host scoping each get their own sentence, so + * the badge never claims a server is old on the strength of missing evidence. + */ + +const VIEW_ONLY_LABEL = (): string => + translate('auto.components.automations.hostStatus.query.viewOnly', 'View only') + +function scopeGapDescriptor(gap: AutomationHostScopeGap): AutomationHostStatusDescriptor { + switch (gap) { + case 'target-removed': + return { + id: 'query-target-removed', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.targetRemovedDescription', + 'This host was removed, so its automations can no longer be edited or run from here.' + ), + tone: 'attention', + isDefault: false + } + case 'target-unverified': + // Why quiet: this is missing evidence, not a fault; it clears on reconnect. + return { + id: 'query-target-unverified', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.targetUnverifiedDescription', + 'This host has not been verified since its connection dropped, so its automations cannot be edited or run from here.' + ), + tone: 'quiet', + isDefault: false + } + case 'target-unregistered': + return { + id: 'query-target-unregistered', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.targetUnregisteredDescription', + 'This host has no registration record, so its automations cannot be edited or run from here.' + ), + tone: 'attention', + isDefault: false + } + case 'authority-unscoped': + return { + id: 'query-legacy-unscoped', + label: VIEW_ONLY_LABEL(), + description: translate( + 'auto.components.automations.hostStatus.query.legacyUnscopedDescription', + 'This server lists automations without host scoping, so they cannot be edited or run from here.' + ), + tone: 'attention', + isDefault: false + } + } +} + +/** Null for `scoped`; the degraded contracts stay distinct rather than sharing one badge. */ +export function hostScopeDescriptor( + entry: Pick +): AutomationHostStatusDescriptor | null { + if (entry.querySupport === 'scoped') { + return null + } + if (entry.querySupport === 'incompatible') { + return { + id: 'query-incompatible', + label: translate( + 'auto.components.automations.hostStatus.query.incompatible', + 'Update server' + ), + description: translate( + 'auto.components.automations.hostStatus.query.incompatibleDescription', + 'This server is too old to scope automations by host. Update it to manage automations here.' + ), + tone: 'attention', + isDefault: false + } + } + // An unscoped contract with no recorded cause can only be the authority's own. + return scopeGapDescriptor(entry.scopeGap ?? 'authority-unscoped') +} diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts new file mode 100644 index 00000000000..9a9262b7130 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-status-descriptors.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationAuthorityHealth, + AutomationExecutionHealth, + AutomationHostCatalogEntry, + AutomationHostScopeGap +} from './automation-host-catalog-types' +import { hostScopeDescriptor } from './automation-host-scope-descriptors' +import { + authorityHealthDescriptor, + automationHostRecoveryActions, + executionHealthDescriptor +} from './automation-host-status-descriptors' + +const AUTHORITY_HEALTHS: AutomationAuthorityHealth[] = [ + 'loading', + 'fresh', + 'refreshing', + 'stale-error', + 'unavailable', + 'incompatible' +] +const SCOPE_GAPS: AutomationHostScopeGap[] = [ + 'target-removed', + 'target-unverified', + 'target-unregistered', + 'authority-unscoped' +] +const EXECUTION_HEALTHS: AutomationExecutionHealth[] = [ + 'connected', + 'connecting', + 'disconnected', + 'unavailable', + 'unknown' +] + +function entry(overrides: Partial = {}): 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 + } +} + +describe('automation host status descriptors', () => { + it('gives every authority health a distinct id and non-empty copy', () => { + const ids = AUTHORITY_HEALTHS.map((health) => authorityHealthDescriptor(health).id) + expect(new Set(ids).size).toBe(AUTHORITY_HEALTHS.length) + for (const health of AUTHORITY_HEALTHS) { + const descriptor = authorityHealthDescriptor(health) + expect(descriptor.label).not.toBe('') + expect(descriptor.description).not.toBe('') + } + }) + + it('gives every execution health a distinct id that cannot collide with an authority id', () => { + const authorityIds = new Set(AUTHORITY_HEALTHS.map((h) => authorityHealthDescriptor(h).id)) + const executionIds = EXECUTION_HEALTHS.map((h) => executionHealthDescriptor(h).id) + expect(new Set(executionIds).size).toBe(EXECUTION_HEALTHS.length) + expect(executionIds.some((id) => authorityIds.has(id))).toBe(false) + }) + + it('treats only fresh and connected as resting states', () => { + expect(AUTHORITY_HEALTHS.filter((h) => authorityHealthDescriptor(h).isDefault)).toEqual([ + 'fresh' + ]) + expect(EXECUTION_HEALTHS.filter((h) => executionHealthDescriptor(h).isDefault)).toEqual([ + 'connected' + ]) + }) + + it('never renders an unknown connection as a failure', () => { + expect(executionHealthDescriptor('unknown').tone).toBe('quiet') + }) + + it('badges only the degraded query contracts, and distinguishes them', () => { + expect(hostScopeDescriptor(entry())).toBeNull() + expect(hostScopeDescriptor(entry({ querySupport: 'incompatible' }))?.id).toBe( + 'query-incompatible' + ) + const gapIds = SCOPE_GAPS.map( + (scopeGap) => hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped', scopeGap }))?.id + ) + expect(new Set(gapIds).size).toBe(SCOPE_GAPS.length) + }) + + it('falls back to the authority contract when no cause was recorded', () => { + expect(hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped' }))?.id).toBe( + 'query-legacy-unscoped' + ) + }) + + it('blames the server only when the server itself answered without host scoping', () => { + const blamesServer = (scopeGap: AutomationHostScopeGap): boolean => + /server/i.test( + hostScopeDescriptor(entry({ querySupport: 'legacy-unscoped', scopeGap }))?.description ?? '' + ) + expect(SCOPE_GAPS.filter(blamesServer)).toEqual(['authority-unscoped']) + }) +}) + +describe('automation host recovery actions', () => { + it('resolves the two axes independently', () => { + expect( + automationHostRecoveryActions( + entry({ authorityHealth: 'stale-error', executionHealth: 'disconnected' }) + ) + ).toEqual({ authority: 'retry', execution: 'reconnect' }) + }) + + it('offers no action while a healthy host is merely loading', () => { + expect(automationHostRecoveryActions(entry({ authorityHealth: 'loading' }))).toEqual({ + authority: null, + execution: null + }) + }) + + it('asks for a server update when the authority itself lacks host scoping', () => { + expect( + automationHostRecoveryActions( + entry({ querySupport: 'legacy-unscoped', scopeGap: 'authority-unscoped' }) + ).authority + ).toBe('update-server') + expect( + automationHostRecoveryActions(entry({ authorityHealth: 'incompatible' })).authority + ).toBe('update-server') + }) + + it('never offers a server update for a host that was removed', () => { + expect( + automationHostRecoveryActions( + entry({ + catalogState: 'removed', + executionHealth: 'unavailable', + querySupport: 'legacy-unscoped', + scopeGap: 'target-removed' + }) + ) + ).toEqual({ authority: null, execution: null }) + }) + + it('offers Reconnect, not a server update, for a target with no verified registration', () => { + expect( + automationHostRecoveryActions( + entry({ + catalogState: 'unhydrated', + executionHealth: 'unknown', + querySupport: 'legacy-unscoped', + scopeGap: 'target-unverified' + }) + ) + ).toEqual({ authority: 'reconnect', execution: null }) + }) + + it('offers no repair for a live target the authority never registered', () => { + expect( + automationHostRecoveryActions( + entry({ querySupport: 'legacy-unscoped', scopeGap: 'target-unregistered' }) + ).authority + ).toBeNull() + }) + + it('reconnects rather than retries when the authority itself is unreachable', () => { + expect(automationHostRecoveryActions(entry({ authorityHealth: 'unavailable' })).authority).toBe( + 'reconnect' + ) + }) + + it('leaves the execution axis alone while the target is connecting', () => { + expect( + automationHostRecoveryActions(entry({ executionHealth: 'connecting' })).execution + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-status-descriptors.ts b/src/renderer/src/components/automations/automation-host-status-descriptors.ts new file mode 100644 index 00000000000..c73df4288ca --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-status-descriptors.ts @@ -0,0 +1,257 @@ +import { translate } from '@/i18n/i18n' +import type { + AutomationAuthorityHealth, + AutomationExecutionHealth, + AutomationHostCatalogEntry, + AutomationHostScopeGap +} from './automation-host-catalog-types' + +/** + * Copy and tone for the two independent health axes. They stay separate all the + * way to the badge: an authority that cannot be queried is a different failure + * from a target that is not connected, and collapsing them hides one of the two. + */ + +export type AutomationHostStatusTone = 'neutral' | 'quiet' | 'attention' | 'error' + +export type AutomationHostStatusDescriptor = { + /** Locale-independent id — the stable hook for tests and CSS, never displayed. */ + id: string + label: string + description: string + tone: AutomationHostStatusTone + /** The healthy resting value; callers hide these unless asked to show everything. */ + isDefault: boolean +} + +/** What a persistent failure offers the user; never a toast (design doc, UX section). */ +export type AutomationHostRecoveryAction = 'retry' | 'reconnect' | 'update-server' + +export type AutomationHostRecoveryActions = { + authority: AutomationHostRecoveryAction | null + execution: AutomationHostRecoveryAction | null +} + +export function authorityHealthDescriptor( + health: AutomationAuthorityHealth +): AutomationHostStatusDescriptor { + switch (health) { + case 'loading': + return { + id: 'authority-loading', + label: translate('auto.components.automations.hostStatus.authority.loading', 'Loading'), + description: translate( + 'auto.components.automations.hostStatus.authority.loadingDescription', + 'Loading automations from this host.' + ), + tone: 'quiet', + isDefault: false + } + case 'fresh': + return { + id: 'authority-fresh', + label: translate('auto.components.automations.hostStatus.authority.fresh', 'Up to date'), + description: translate( + 'auto.components.automations.hostStatus.authority.freshDescription', + 'Automations loaded from this host.' + ), + tone: 'neutral', + isDefault: true + } + case 'refreshing': + return { + id: 'authority-refreshing', + label: translate( + 'auto.components.automations.hostStatus.authority.refreshing', + 'Refreshing' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.refreshingDescription', + 'Checking this host for changes.' + ), + tone: 'quiet', + isDefault: false + } + case 'stale-error': + // Why: the rows on screen are real, just not re-verified — say that, don't call them stale data. + return { + id: 'authority-stale-error', + label: translate( + 'auto.components.automations.hostStatus.authority.staleError', + 'Not refreshed' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.staleErrorDescription', + 'Showing the automations last loaded from this host. The most recent refresh did not complete.' + ), + tone: 'attention', + isDefault: false + } + case 'unavailable': + return { + id: 'authority-unavailable', + label: translate( + 'auto.components.automations.hostStatus.authority.unavailable', + 'Unreachable' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.unavailableDescription', + 'This host could not be reached, so its automations could not be loaded.' + ), + tone: 'error', + isDefault: false + } + case 'incompatible': + return { + id: 'authority-incompatible', + label: translate( + 'auto.components.automations.hostStatus.authority.incompatible', + 'Update server' + ), + description: translate( + 'auto.components.automations.hostStatus.authority.incompatibleDescription', + 'This server does not support host-scoped automation queries. Update it to load automations for this host.' + ), + tone: 'attention', + isDefault: false + } + } +} + +export function executionHealthDescriptor( + health: AutomationExecutionHealth +): AutomationHostStatusDescriptor { + switch (health) { + case 'connected': + return { + id: 'execution-connected', + label: translate('auto.components.automations.hostStatus.execution.connected', 'Connected'), + description: translate( + 'auto.components.automations.hostStatus.execution.connectedDescription', + 'This host is connected and can run automations.' + ), + tone: 'neutral', + isDefault: true + } + case 'connecting': + return { + id: 'execution-connecting', + label: translate( + 'auto.components.automations.hostStatus.execution.connecting', + 'Connecting' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.connectingDescription', + 'Connecting to this host.' + ), + tone: 'quiet', + isDefault: false + } + case 'disconnected': + return { + id: 'execution-disconnected', + label: translate( + 'auto.components.automations.hostStatus.execution.disconnected', + 'Not connected' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.disconnectedDescription', + 'Automations on this host cannot run until the connection is restored.' + ), + tone: 'attention', + isDefault: false + } + case 'unavailable': + return { + id: 'execution-unavailable', + label: translate( + 'auto.components.automations.hostStatus.execution.unavailable', + 'Unavailable' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.unavailableDescription', + 'This execution target is no longer available.' + ), + tone: 'error', + isDefault: false + } + case 'unknown': + // Why: not knowing is not the same as being down; never render this as a failure. + return { + id: 'execution-unknown', + label: translate( + 'auto.components.automations.hostStatus.execution.unknown', + 'Connection unknown' + ), + description: translate( + 'auto.components.automations.hostStatus.execution.unknownDescription', + 'The connection state for this host has not loaded yet.' + ), + tone: 'quiet', + isDefault: false + } + } +} + +export function automationHostRecoveryActions( + entry: AutomationHostCatalogEntry +): AutomationHostRecoveryActions { + return { + authority: authorityRecoveryAction(entry), + execution: executionRecoveryAction(entry.executionHealth) + } +} + +function authorityRecoveryAction( + entry: AutomationHostCatalogEntry +): AutomationHostRecoveryAction | null { + if (entry.authorityHealth === 'incompatible' || entry.querySupport === 'incompatible') { + return 'update-server' + } + if (entry.authorityHealth === 'unavailable') { + // The transport to the authority is down, so retrying the query alone cannot help. + return 'reconnect' + } + if (entry.querySupport !== 'scoped') { + return scopeGapRecoveryAction(entry.scopeGap) + } + return entry.authorityHealth === 'stale-error' ? 'retry' : null +} + +/** Updating a server only repairs a server that actually answered without host scoping. */ +function scopeGapRecoveryAction( + gap: AutomationHostScopeGap | undefined +): AutomationHostRecoveryAction | null { + switch (gap) { + case 'target-unverified': + return 'reconnect' + case 'target-removed': + case 'target-unregistered': + return null + // An unscoped contract with no recorded cause can only be the authority's own. + case 'authority-unscoped': + case undefined: + return 'update-server' + } +} + +function executionRecoveryAction( + health: AutomationExecutionHealth +): AutomationHostRecoveryAction | null { + // `unavailable` is only reached for a removed target, which reconnecting cannot revive. + return health === 'disconnected' ? 'reconnect' : null +} + +export function recoveryActionLabel(action: AutomationHostRecoveryAction): string { + switch (action) { + case 'retry': + return translate('auto.components.automations.hostStatus.action.retry', 'Retry') + case 'reconnect': + return translate('auto.components.automations.hostStatus.action.reconnect', 'Reconnect') + case 'update-server': + return translate( + 'auto.components.automations.hostStatus.action.updateServer', + 'Update server' + ) + } +} diff --git a/src/renderer/src/components/automations/automation-list-empty-state.test.ts b/src/renderer/src/components/automations/automation-list-empty-state.test.ts new file mode 100644 index 00000000000..571dbc8ae27 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-empty-state.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import type { + AutomationExecutionHealth, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import { + resolveAutomationHostGroupEmptyState, + resolveAutomationListEmptyState, + type AutomationListEmptyStateInput, + type AutomationListHostGroupEmptyStateInput +} from './automation-list-empty-state' + +function entry(overrides: Partial = {}): 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 hostResolution( + overrides: Partial = {} +): AutomationHostFilterResolution { + const resolved = overrides.entry === undefined ? entry() : overrides.entry + return { + effective: { kind: 'host', host: (resolved ?? entry()).stableRef }, + entry: resolved, + status: 'ready', + announceFallback: false, + ...overrides + } +} + +function input( + overrides: Partial = {} +): AutomationListEmptyStateInput { + return { + resolution: hostResolution(), + hostRowCount: 0, + visibleRowCount: 0, + searchActive: false, + externalManagersListed: true, + ...overrides + } +} + +/** The grouped All-hosts view: one host, its own counts, no selection to resolve. */ +function groupInput( + overrides: Partial = {} +): AutomationListHostGroupEmptyStateInput { + return { + entry: entry(), + hostRowCount: 0, + visibleRowCount: 0, + searchActive: false, + externalManagersListed: true, + ...overrides + } +} + +const ALL_HOSTS: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false +} + +describe('automation list empty state', () => { + it('renders nothing when rows are visible', () => { + expect(resolveAutomationListEmptyState(input({ visibleRowCount: 3 })).kind).toBe('rows') + }) + + it('uses the single-host empty string only for a healthy, connected, authoritative host', () => { + const state = resolveAutomationListEmptyState(input()) + expect(state.kind).toBe('host-empty') + expect(state.title).toBe('No automations on web-01') + }) + + it('uses the all-hosts string only under All hosts', () => { + const state = resolveAutomationListEmptyState(input({ resolution: ALL_HOSTS })) + expect(state.kind).toBe('all-hosts-empty') + expect(state.title).toBe('No automations across loaded hosts') + }) + + it('uses the search string only when the host has rows the query hid', () => { + const state = resolveAutomationListEmptyState( + input({ searchActive: true, hostRowCount: 5, visibleRowCount: 0 }) + ) + expect(state.kind).toBe('search-no-match') + expect(state.title).toBe('No automations match your search') + }) + + it('does not claim a search miss when the host itself has no rows', () => { + const state = resolveAutomationListEmptyState( + input({ searchActive: true, hostRowCount: 0, visibleRowCount: 0 }) + ) + expect(state.kind).toBe('host-empty') + }) + + it('uses the load-failure string when the authority could not be reached', () => { + const state = resolveAutomationListEmptyState( + input({ + resolution: hostResolution({ + entry: entry({ authorityHealth: 'unavailable' }), + status: 'unavailable' + }) + }) + ) + expect(state.kind).toBe('host-unavailable') + expect(state.title).toBe('Automations could not be loaded from web-01') + expect(state.recovery).toBe('reconnect') + }) + + it('offers Retry rather than Reconnect for a stale refresh', () => { + const state = resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ authorityHealth: 'stale-error' }) }) }) + ) + expect(state.kind).toBe('host-error') + expect(state.title).toBe('Automations could not be loaded from web-01') + expect(state.recovery).toBe('retry') + }) + + it('offers Update server for an incompatible authority', () => { + const state = resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ authorityHealth: 'incompatible' }) }) }) + ) + expect(state.kind).toBe('host-error') + expect(state.recovery).toBe('update-server') + }) + + // The rule the design doc calls out explicitly. + const NOT_CONNECTED: AutomationExecutionHealth[] = [ + 'disconnected', + 'connecting', + 'unavailable', + 'unknown' + ] + it.each(NOT_CONNECTED)('never calls a %s host empty', (executionHealth) => { + const state = resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ executionHealth }) }) }) + ) + expect(state.kind).toBe('host-not-connected') + expect(state.title).not.toContain('No automations') + expect(state.title).toBe('web-01 is not connected') + }) + + it('never calls an unhydrated host empty', () => { + const state = resolveAutomationListEmptyState( + input({ + resolution: hostResolution({ + entry: entry({ catalogState: 'unhydrated' }), + status: 'loading' + }) + }) + ) + expect(state.kind).toBe('host-loading') + expect(state.title).toBe('Loading host…') + expect(state.title).not.toContain('No automations') + }) + + it('states the external-manager scope limit for a runtime-owned host', () => { + const state = resolveAutomationListEmptyState(input({ externalManagersListed: false })) + expect(state.kind).toBe('host-empty') + expect(state.scopeNote).toBe( + 'External automation managers are not listed for this host in this release.' + ) + }) + + it('omits the scope note when external managers are listed', () => { + expect(resolveAutomationListEmptyState(input()).scopeNote).toBeNull() + }) + + it('never claims a host the group could not reach is empty', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ entry: entry({ authorityHealth: 'unavailable' }) }) + ) + expect(state.kind).toBe('host-unavailable') + expect(state.title).toBe('Automations could not be loaded from web-01') + expect(state.recovery).toBe('reconnect') + }) + + it.each(NOT_CONNECTED)('never calls a %s host in a group empty', (executionHealth) => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ entry: entry({ executionHealth }) }) + ) + expect(state.kind).toBe('host-not-connected') + expect(state.title).not.toContain('No automations') + }) + + it('never calls an unhydrated host in a group empty', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ entry: entry({ catalogState: 'unhydrated' }) }) + ) + expect(state.kind).toBe('host-loading') + expect(state.title).not.toContain('No automations') + }) + + it('blames the query, not the host, when a group is emptied by search', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ searchActive: true, hostRowCount: 3, visibleRowCount: 0 }) + ) + expect(state.kind).toBe('search-no-match') + expect(state.title).toBe('No automations match your search') + }) + + it('still renders rows for a group whose host answered with some', () => { + expect(resolveAutomationHostGroupEmptyState(groupInput({ visibleRowCount: 2 })).kind).toBe( + 'rows' + ) + }) + + it('calls a healthy, connected, authoritative group host empty', () => { + const state = resolveAutomationHostGroupEmptyState(groupInput()) + expect(state.kind).toBe('host-empty') + expect(state.title).toBe('No automations on web-01') + }) + + it('states the external-manager scope limit per group host', () => { + const state = resolveAutomationHostGroupEmptyState( + groupInput({ externalManagersListed: false }) + ) + expect(state.scopeNote).toBe( + 'External automation managers are not listed for this host in this release.' + ) + }) + + it('keeps each empty string exclusive to its own condition', () => { + const titles = [ + resolveAutomationListEmptyState(input()).title, + resolveAutomationListEmptyState(input({ resolution: ALL_HOSTS })).title, + resolveAutomationListEmptyState(input({ searchActive: true, hostRowCount: 2 })).title, + resolveAutomationListEmptyState( + input({ resolution: hostResolution({ entry: entry({ authorityHealth: 'stale-error' }) }) }) + ).title + ] + expect(new Set(titles).size).toBe(4) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-empty-state.ts b/src/renderer/src/components/automations/automation-list-empty-state.ts new file mode 100644 index 00000000000..b552e7ffd12 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-empty-state.ts @@ -0,0 +1,225 @@ +import { translate } from '@/i18n/i18n' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { + AutomationHostFilterResolution, + AutomationHostFilterStatus +} from './automation-host-filter-resolution' +import { + automationHostRecoveryActions, + type AutomationHostRecoveryAction +} from './automation-host-status-descriptors' + +/** + * Picks which empty/partial/failure state the list is actually in. + * + * The load-bearing rule: a host that is disconnected, unreachable, or not yet + * hydrated is never described as empty. "No automations" is a claim about + * storage, and none of those states have looked at storage. + */ + +export type AutomationListEmptyStateKind = + /** Rows exist; render the list, not a state. */ + | 'rows' + | 'search-no-match' + | 'host-empty' + | 'all-hosts-empty' + | 'host-error' + | 'host-loading' + | 'host-not-connected' + | 'host-unavailable' + +export type AutomationListEmptyState = { + kind: AutomationListEmptyStateKind + title: string + detail: string | null + /** Runtime-owned hosts list no external managers yet; a scope-limited host is never shown as clean. */ + scopeNote: string | null + recovery: AutomationHostRecoveryAction | null +} + +export type AutomationListEmptyStateInput = { + resolution: AutomationHostFilterResolution + /** Rows for the selected host before the search query is applied. */ + hostRowCount: number + /** Rows still visible after the search query. */ + visibleRowCount: number + searchActive: boolean + /** + * False when this host's external automation managers are out of scope for + * the release. Passed in rather than derived so the list never guesses. + */ + externalManagersListed: boolean +} + +function hostLabel(input: AutomationListEmptyStateInput): string { + return ( + input.resolution.entry?.label ?? + translate('auto.components.automations.emptyState.unknownHost', 'this host') + ) +} + +function scopeNoteFor(input: AutomationListEmptyStateInput): string | null { + return input.externalManagersListed + ? null + : translate( + 'auto.components.automations.emptyState.externalManagersOutOfScope', + 'External automation managers are not listed for this host in this release.' + ) +} + +function state( + kind: AutomationListEmptyStateKind, + title: string, + detail: string | null, + input: AutomationListEmptyStateInput, + recovery: AutomationHostRecoveryAction | null = null +): AutomationListEmptyState { + return { kind, title, detail, scopeNote: scopeNoteFor(input), recovery } +} + +function resolveSelectedHostState(input: AutomationListEmptyStateInput): AutomationListEmptyState { + const label = hostLabel(input) + const entry = input.resolution.entry + const recovery = entry ? automationHostRecoveryActions(entry) : null + + if (input.resolution.status === 'loading' || entry?.catalogState === 'unhydrated') { + return state( + 'host-loading', + translate('auto.components.automations.emptyState.hostLoading', 'Loading host…'), + translate( + 'auto.components.automations.emptyState.hostLoadingDetail', + 'Waiting for {{hostLabel}} to report its automations.', + { hostLabel: label } + ), + input + ) + } + if (input.resolution.status === 'unavailable' || entry?.authorityHealth === 'unavailable') { + return state( + 'host-unavailable', + translate( + 'auto.components.automations.emptyState.hostError', + 'Automations could not be loaded from {{hostLabel}}', + { hostLabel: label } + ), + translate( + 'auto.components.automations.emptyState.hostUnavailableDetail', + 'The host could not be reached, so its automations are unknown.' + ), + input, + recovery?.authority ?? 'reconnect' + ) + } + if (entry?.authorityHealth === 'stale-error' || entry?.authorityHealth === 'incompatible') { + return state( + 'host-error', + translate( + 'auto.components.automations.emptyState.hostError', + 'Automations could not be loaded from {{hostLabel}}', + { hostLabel: label } + ), + entry.authorityHealth === 'incompatible' + ? translate( + 'auto.components.automations.emptyState.hostIncompatibleDetail', + 'This server is too old to scope automations by host.' + ) + : translate( + 'auto.components.automations.emptyState.hostStaleDetail', + 'The most recent refresh did not complete.' + ), + input, + recovery?.authority ?? 'retry' + ) + } + if (entry && entry.executionHealth !== 'connected') { + // Why: an unconnected target has told us nothing about storage, so "no automations" would overclaim. + return state( + 'host-not-connected', + translate( + 'auto.components.automations.emptyState.hostNotConnected', + '{{hostLabel}} is not connected', + { hostLabel: label } + ), + translate( + 'auto.components.automations.emptyState.hostNotConnectedDetail', + 'Connect to this host to see the automations stored for it.' + ), + input, + recovery?.execution ?? 'reconnect' + ) + } + return state( + 'host-empty', + translate( + 'auto.components.automations.emptyState.hostEmpty', + 'No automations on {{hostLabel}}', + { hostLabel: label } + ), + null, + input + ) +} + +export type AutomationListHostGroupEmptyStateInput = Omit< + AutomationListEmptyStateInput, + 'resolution' +> & { entry: AutomationHostCatalogEntry } + +function groupHostStatus(entry: AutomationHostCatalogEntry): AutomationHostFilterStatus { + if (entry.catalogState === 'unhydrated') { + return 'loading' + } + return entry.authorityHealth === 'unavailable' ? 'unavailable' : 'ready' +} + +/** + * The All-hosts group asks the same question about one host that the single-host + * view asks, so it asks it here. A second set of branches would drift, and the + * drift is what lets a host nobody could reach be described as empty. + */ +export function resolveAutomationHostGroupEmptyState( + input: AutomationListHostGroupEmptyStateInput +): AutomationListEmptyState { + const { entry, ...counts } = input + return resolveAutomationListEmptyState({ + ...counts, + resolution: { + effective: { kind: 'host', host: entry.stableRef }, + entry, + status: groupHostStatus(entry), + announceFallback: false + } + }) +} + +export function resolveAutomationListEmptyState( + input: AutomationListEmptyStateInput +): AutomationListEmptyState { + if (input.visibleRowCount > 0) { + return { kind: 'rows', title: '', detail: null, scopeNote: null, recovery: null } + } + if (input.searchActive && input.hostRowCount > 0) { + // The host has rows; only the query emptied the view. + return state( + 'search-no-match', + translate( + 'auto.components.automations.emptyState.searchNoMatch', + 'No automations match your search' + ), + null, + { ...input, externalManagersListed: true } + ) + } + if (input.resolution.effective.kind === 'all') { + return state( + 'all-hosts-empty', + translate( + 'auto.components.automations.emptyState.allHostsEmpty', + 'No automations across loaded hosts' + ), + null, + input + ) + } + return resolveSelectedHostState(input) +} diff --git a/src/renderer/src/components/automations/automation-list-focus-recovery.test.ts b/src/renderer/src/components/automations/automation-list-focus-recovery.test.ts new file mode 100644 index 00000000000..06e0f6240ba --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-focus-recovery.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { resolveAutomationListFocusRecovery } from './automation-list-focus-recovery' + +describe('automation list focus recovery', () => { + it('leaves focus alone when the row survived', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b', 'c'], + nextRowKeys: ['a', 'b', 'c'], + focusedRowKey: 'b' + }) + ).toBeNull() + }) + + it('leaves focus alone when it was never on a row', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a'], + nextRowKeys: [], + focusedRowKey: null + }) + ).toBeNull() + }) + + it('moves to the next surviving row', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b', 'c'], + nextRowKeys: ['a', 'c'], + focusedRowKey: 'b' + }) + ).toEqual({ kind: 'row', rowKey: 'c' }) + }) + + it('falls back to the previous row when nothing follows survived', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b', 'c'], + nextRowKeys: ['a'], + focusedRowKey: 'b' + }) + ).toEqual({ kind: 'row', rowKey: 'a' }) + }) + + it('falls back to the picker when the whole list went', () => { + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b'], + nextRowKeys: [], + focusedRowKey: 'b' + }) + ).toEqual({ kind: 'picker' }) + }) + + it('goes to the picker when the lost row was never in this list', () => { + // A host switch replaces every row, so no neighbor here is the right one. + expect( + resolveAutomationListFocusRecovery({ + previousRowKeys: ['a', 'b'], + nextRowKeys: ['x', 'y'], + focusedRowKey: 'z' + }) + ).toEqual({ kind: 'picker' }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-focus-recovery.ts b/src/renderer/src/components/automations/automation-list-focus-recovery.ts new file mode 100644 index 00000000000..ebf30db9a0b --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-focus-recovery.ts @@ -0,0 +1,52 @@ +/** + * Where focus goes when the row the user was on disappears. + * + * A refresh, a host filter change, or a delete can remove the focused row while + * the keyboard is still in the list. Without this, focus falls back to the + * document body and the next Tab restarts from the top of the page. The order — + * next row, then previous row, then the host picker — keeps the user near where + * they were, and the picker is the last resort because it is the control that + * put them in this list to begin with. + */ + +export type AutomationListFocusTarget = { kind: 'row'; rowKey: string } | { kind: 'picker' } + +export type AutomationListFocusRecoveryInput = { + /** Row order before the change, including the row that is now gone. */ + previousRowKeys: readonly string[] + nextRowKeys: readonly string[] + /** The row that had focus; null when focus was elsewhere. */ + focusedRowKey: string | null +} + +/** Null means focus is fine where it is and must not be moved. */ +export function resolveAutomationListFocusRecovery( + input: AutomationListFocusRecoveryInput +): AutomationListFocusTarget | null { + const { focusedRowKey } = input + if (focusedRowKey === null) { + return null + } + const surviving = new Set(input.nextRowKeys) + if (surviving.has(focusedRowKey)) { + return null + } + const lostIndex = input.previousRowKeys.indexOf(focusedRowKey) + if (lostIndex === -1) { + // The row was never in this list, so nothing here can be the right neighbor. + return { kind: 'picker' } + } + for (let index = lostIndex + 1; index < input.previousRowKeys.length; index += 1) { + const candidate = input.previousRowKeys[index] + if (surviving.has(candidate)) { + return { kind: 'row', rowKey: candidate } + } + } + for (let index = lostIndex - 1; index >= 0; index -= 1) { + const candidate = input.previousRowKeys[index] + if (surviving.has(candidate)) { + return { kind: 'row', rowKey: candidate } + } + } + return { kind: 'picker' } +} diff --git a/src/renderer/src/components/automations/automation-list-row-identity.test.ts b/src/renderer/src/components/automations/automation-list-row-identity.test.ts new file mode 100644 index 00000000000..a1e89da8b8c --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-identity.test.ts @@ -0,0 +1,139 @@ +/** + * One automation ID under two authorities, followed from the cached host rows + * all the way into the search index. + * + * These are the joins the list makes between a record and its host. Each one + * used to be a bare automation ID, which is unique only inside one authority + * (doc:38) — so the desktop's `a-1` and a runtime SSH host's `a-1` shared a + * slot, and whichever was written last won it. + */ + +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCacheEntry, AutomationHostRow } from './automation-host-cache-types' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import type { Repo } from '../../../../shared/repo-types' +import { resolveAutomationHostListRows } from './automation-host-list-rows' +import { + buildAutomationListSearchRows, + buildAutomationSearchRowSources, + matchAutomationListSearchRowKeys +} from './automation-list-search-rows' +import { REPO_ID } from './automations-page-fixtures' + +const repoMap = new Map([ + [REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo] +]) + +const DESKTOP_SELF: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +const RUNTIME_SSH: AutomationHostCatalogEntry = { + ...DESKTOP_SELF, + stableRef: { + authority: { kind: 'runtime', environmentId: 'gpu' }, + selector: { kind: 'ssh', targetId: 'web-01' } + }, + owner: { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 4 }, + selector: { kind: 'ssh', targetId: 'web-01', targetGeneration: 2 } + }, + stableKey: 'host:runtime:gpu:ssh:web-01', + label: 'web-01', + authorityLabel: 'GPU box', + kind: 'ssh' +} + +const ALL_HOSTS: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false +} + +function cached(rows: AutomationHostRow[]): AutomationHostCacheEntry { + return { + data: rows, + fetchedAt: 1, + attempt: 0, + requestGeneration: 0, + catalogGeneration: 0, + request: null, + error: null, + orphanCount: null + } +} + +function hostRow(entry: AutomationHostCatalogEntry, name: string): AutomationHostRow { + return { + automation: { + id: 'a-1', + name, + prompt: 'sweep', + projectId: REPO_ID, + workspaceMode: 'new_per_run', + baseBranch: null, + agentId: 'claude' + } as Automation, + owner: entry.owner, + selector: { kind: 'self' }, + usageSummary: null, + usageKnown: true + } +} + +/** The desktop and a runtime SSH host each holding a record called `a-1`. */ +function collidedListRows(): ReturnType { + const entries = [DESKTOP_SELF, RUNTIME_SSH] + const catalog: AutomationHostCatalog = { + entries, + byStableKey: new Map(entries.map((entry) => [entry.stableKey, entry])), + hydration: { + runtimeCatalogSettled: true, + desktopSshHydrated: true, + runtimeSshHydratedByEnvironmentId: new Map(), + savedRuntimeEnvironmentIds: new Set(), + orphanSettledAuthorityKeys: new Set(), + unavailableAuthorityKeys: new Set() + } + } + return resolveAutomationHostListRows({ + catalog, + resolution: ALL_HOSTS, + entry: (stableKey) => + cached([ + stableKey === DESKTOP_SELF.stableKey + ? hostRow(DESKTOP_SELF, 'Nightly desktop') + : hostRow(RUNTIME_SSH, 'Nightly web-01') + ]) + }) +} + +describe('automation list row identity across hosts', () => { + it('lets each host’s row be found by its own host label', () => { + const listRows = collidedListRows() + const searchRows = buildAutomationListSearchRows( + buildAutomationSearchRowSources(listRows.rows, { repoMap }) + ) + const nameByKey = new Map(listRows.rows.map((row) => [row.key, row.automation.name])) + const namesMatching = (query: string): (string | undefined)[] => + matchAutomationListSearchRowKeys(searchRows, query).map((key) => nameByKey.get(key)) + + expect(namesMatching('web-01')).toEqual(['Nightly web-01']) + expect(namesMatching('this computer')).toEqual(['Nightly desktop']) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-row-identity.ts b/src/renderer/src/components/automations/automation-list-row-identity.ts new file mode 100644 index 00000000000..d646699920b --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-identity.ts @@ -0,0 +1,60 @@ +/** + * Identity for a rendered automation list row. + * + * An automation ID is unique only inside its authority, so under All hosts two + * hosts can legitimately return `a-1` and every bare-ID map, React key, and + * selection check silently collapses them into one. A row is therefore keyed by + * the host it came from plus the record's ID. + * + * The host part is the incarnation-free `stableKey`, never `ownerKey`: this key + * decides React identity and which row is selected, and both must survive a + * re-pairing that bumps `pairingRevision` / `targetGeneration`. An + * incarnation-bearing key would remount every row and drop the selection each + * time a host reconnected. Freshness fences still use `ownerKey` — they are + * asking a different question about the same row. + */ + +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary' + +export type AutomationListRow = { + /** Authority-qualified and incarnation-free; see the module note. */ + key: string + automation: Automation + /** The host this row actually came from; empty for unscoped legacy rows. */ + hostLabel: string + /** What the owning authority reported; null means unknown, never zero. */ + usageSummary: AutomationUsageSummary | null +} + +const ROW_KEY_NAMESPACE = 'row' +/** The pre-catalog list has no host to qualify with, so it says so rather than guessing one. */ +const UNSCOPED_HOST_KEY = 'unscoped' + +export function automationListRowKey(hostStableKey: string | null, automationId: string): string { + return [ROW_KEY_NAMESPACE, hostStableKey ?? UNSCOPED_HOST_KEY, automationId] + .map(encodeURIComponent) + .join('|') +} + +/** + * Dedupe key for a record within one authority. Deliberately coarser than the + * row key: an authority's Self, SSH, and orphan scopes can each return the same + * record and only one should render, while two authorities holding that ID hold + * two different records. + */ +export function automationAuthorityRecordKey(authorityKey: string, automationId: string): string { + return [authorityKey, automationId].map(encodeURIComponent).join('|') +} + +/** The unscoped list the page shows before any host has answered. */ +export function unscopedAutomationListRows( + automations: readonly Automation[] +): AutomationListRow[] { + return automations.map((automation) => ({ + key: automationListRowKey(null, automation.id), + automation, + hostLabel: '', + usageSummary: null + })) +} diff --git a/src/renderer/src/components/automations/automation-list-search-query.ts b/src/renderer/src/components/automations/automation-list-search-query.ts new file mode 100644 index 00000000000..85c6067daaa --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search-query.ts @@ -0,0 +1,56 @@ +import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' + +/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ +export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 + +export type AutomationListSearchQueryResolution = + | { status: 'inactive' } + | { status: 'too_large' } + | { status: 'active'; query: string } + +export function isAutomationListSearchQueryTooLarge( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) +} + +/** + * Caps the controlled input value so a multi-MB paste cannot pin renderer + * memory. Keeping maxBytes+1 code units is enough for the over-limit check + * (`length > maxBytes`) while discarding the rest of the paste. + */ +export function clampAutomationListSearchQueryInput( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string { + const maxStoredCodeUnits = maxBytes + 1 + if (rawQuery.length <= maxStoredCodeUnits) { + return rawQuery + } + return rawQuery.slice(0, maxStoredCodeUnits) +} + +export function resolveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): AutomationListSearchQueryResolution { + // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. + if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { + return { status: 'too_large' } + } + const query = rawQuery.trim().toLowerCase() + if (!query) { + return { status: 'inactive' } + } + return { status: 'active', query } +} + +/** Active lowercase query, or null when search must not run. */ +export function getActiveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string | null { + const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) + return resolved.status === 'active' ? resolved.query : null +} diff --git a/src/renderer/src/components/automations/automation-list-search-rows.test.ts b/src/renderer/src/components/automations/automation-list-search-rows.test.ts new file mode 100644 index 00000000000..c81571fcb2f --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search-rows.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { getAgentLabel } from './automation-draft-model' +import { + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, + automationListSearchIndexMatches +} from './automation-list-search' +import { + buildAutomationListSearchRowFingerprint, + buildAutomationListSearchRows, + buildAutomationSearchFields, + buildAutomationSearchRowSources, + buildExternalAutomationSearchFields, + buildExternalAutomationSearchRowSources, + matchAutomationListSearchRowKeys +} from './automation-list-search-rows' +import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { + makeAutomation, + makeAutomationListRow, + makeScopedExternalManager, + REPO_ID +} from './automations-page-fixtures' + +const repo = { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo +const repoMap = new Map([[REPO_ID, repo]]) + +function fieldsFor( + row = makeAutomationListRow({ hostLabel: '' }), + context: Parameters[1] = { repoMap } +): ReturnType { + return buildAutomationSearchFields(row, context) +} + +describe('automation search row fields', () => { + it('indexes every axis the design doc requires for a local automation', () => { + const automation = makeAutomation({ + id: 'a-9', + name: 'Nightly sweep', + workspaceMode: 'existing', + workspaceId: 'ws-1', + agentId: 'claude' + }) + const fields = fieldsFor(makeAutomationListRow({ automation, hostLabel: 'build-box' }), { + repoMap, + worktreeMap: new Map([['ws-1', { displayName: 'feature/login-retry' }]]) + }) + + expect(fields).toEqual({ + name: 'Nightly sweep', + project: 'orca /src/orca', + workspace: 'feature/login-retry', + agent: getAgentLabel('claude'), + host: 'build-box', + prompt: automation.prompt + }) + }) + + it('falls back to the base ref for automations that create a workspace per run', () => { + const fields = fieldsFor( + makeAutomationListRow({ + automation: makeAutomation({ workspaceMode: 'new_per_run', baseBranch: 'main' }) + }) + ) + expect(fields.workspace).toBe('main') + }) + + it('keeps the unknown-project term when no repo resolves', () => { + const fields = fieldsFor( + makeAutomationListRow({ automation: makeAutomation({ projectId: 'missing' }) }), + { repoMap } + ) + expect(fields.project).toBe(AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT) + }) + + it('leaves the host axis empty for a row with no host of origin', () => { + expect(fieldsFor().host).toBe('') + }) + + it('indexes external jobs by provider, target host, and workdir', () => { + const [entry] = buildExternalAutomationListEntries([makeScopedExternalManager()]) + if (!entry) { + throw new Error('fixture produced no external entry') + } + const fields = buildExternalAutomationSearchFields(entry) + expect(fields.name).toBe('Hermes job') + expect(fields.agent).toBe('Hermes') + expect(fields.host).toBe('This computer') + expect(fields.prompt).toBe('Sweep') + }) +}) + +describe('automation search row index', () => { + it('builds one index per row and matches in a single pass', () => { + const listRows = [ + makeAutomationListRow({ automation: makeAutomation({ id: 'a-1', name: 'Nightly sweep' }) }), + makeAutomationListRow({ + automation: makeAutomation({ id: 'a-2', name: 'PR nudge', prompt: 'remind reviewers' }), + hostLabel: 'build-box' + }) + ] + const [first, second] = listRows.map((row) => row.key) + const rows = buildAutomationListSearchRows( + buildAutomationSearchRowSources(listRows, { repoMap }) + ) + + expect(rows.map((row) => row.key)).toEqual([first, second]) + expect(matchAutomationListSearchRowKeys(rows, 'reviewers')).toEqual([second]) + expect(matchAutomationListSearchRowKeys(rows, 'build-box')).toEqual([second]) + expect(matchAutomationListSearchRowKeys(rows, 'orca')).toEqual([first, second]) + expect(matchAutomationListSearchRowKeys(rows, 'nothing')).toEqual([]) + }) + + it('bounds prompt indexing per row regardless of prompt size', () => { + const prompt = `${'x'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)}unique-tail` + const rows = buildAutomationListSearchRows( + buildAutomationSearchRowSources( + [makeAutomationListRow({ automation: makeAutomation({ prompt }) })], + { + repoMap + } + ) + ) + const index = rows[0]?.index + if (!index) { + throw new Error('no row built') + } + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(automationListSearchIndexMatches(index, 'unique-tail')).toBe(false) + }) + + it('fingerprints identical content identically across a refresh tick', () => { + const build = (): ReturnType => + buildAutomationSearchRowSources( + [makeAutomationListRow({ automation: makeAutomation({ nextRunAt: Date.now() }) })], + { repoMap } + ) + // Why: nextRunAt churns on every tick and must not invalidate the index. + expect(buildAutomationListSearchRowFingerprint(build())).toBe( + buildAutomationListSearchRowFingerprint(build()) + ) + }) + + it('changes the fingerprint when a searchable axis changes', () => { + const base = buildAutomationSearchRowSources([makeAutomationListRow({ hostLabel: '' })], { + repoMap + }) + const renamedHost = buildAutomationSearchRowSources( + [makeAutomationListRow({ hostLabel: 'build-box' })], + { repoMap } + ) + expect(buildAutomationListSearchRowFingerprint(base)).not.toBe( + buildAutomationListSearchRowFingerprint(renamedHost) + ) + }) + + it('fingerprints external rows by key and content', () => { + const entries = buildExternalAutomationListEntries([makeScopedExternalManager()]) + const sources = buildExternalAutomationSearchRowSources(entries) + expect(buildAutomationListSearchRowFingerprint(sources)).toBe( + buildAutomationListSearchRowFingerprint( + buildExternalAutomationSearchRowSources( + buildExternalAutomationListEntries([makeScopedExternalManager()]) + ) + ) + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-search-rows.ts b/src/renderer/src/components/automations/automation-list-search-rows.ts new file mode 100644 index 00000000000..c8276415a94 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search-rows.ts @@ -0,0 +1,136 @@ +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import type { Automation } from '../../../../shared/automations-types' +import type { Repo } from '../../../../shared/repo-types' +import type { AutomationListRow } from './automation-list-row-identity' +import { getAgentLabel } from './automation-draft-model' +import { + automationListSearchIndexMatches, + buildAutomationListSearchFingerprint, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + type AutomationListSearchFields, + type AutomationListSearchIndex +} from './automation-list-search' +import { getExternalProviderLabel } from './external-automation-display' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' + +/** Row identity is the authority-qualified row key / external key, so reorders invalidate the index. */ +export type AutomationListSearchRowSource = { + key: string + fields: AutomationListSearchFields +} + +export type AutomationListSearchRow = { + key: string + index: AutomationListSearchIndex +} + +/** Only display names are read; the map accepts the page's worktree map as-is. */ +export type AutomationWorkspaceNameLookup = ReadonlyMap + +export type AutomationSearchRowContext = { + repoMap: ReadonlyMap + worktreeMap?: AutomationWorkspaceNameLookup +} + +function buildAutomationWorkspaceSearchText( + automation: Automation, + worktreeMap: AutomationWorkspaceNameLookup | undefined +): string { + // Why: a per-run automation has no workspace yet, so its base ref is the only durable term. + if (automation.workspaceMode === 'new_per_run') { + return automation.baseBranch ?? '' + } + if (!automation.workspaceId) { + return '' + } + return worktreeMap?.get(automation.workspaceId)?.displayName ?? '' +} + +export function buildAutomationSearchFields( + row: AutomationListRow, + context: AutomationSearchRowContext +): AutomationListSearchFields { + const { automation } = row + const repo = context.repoMap.get(getAutomationRunRepoId(automation)) + return { + name: automation.name, + project: buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }), + workspace: buildAutomationWorkspaceSearchText(automation, context.worktreeMap), + agent: getAgentLabel(automation.agentId), + // The row's own host: a lookup by automation id would give two hosts' rows + // one label, so a search for host B could only ever match host A's row. + host: row.hostLabel, + prompt: automation.prompt + } +} + +export function buildExternalAutomationSearchFields( + entry: ExternalAutomationListEntry +): AutomationListSearchFields { + return { + name: entry.job.name, + project: entry.job.workdir ?? '', + workspace: '', + // Why: an external job runs under its manager, so the provider is the closest agent term. + agent: getExternalProviderLabel(entry.manager), + host: entry.manager.targetLabel, + prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' + } +} + +export function buildAutomationSearchRowSources( + rows: readonly AutomationListRow[], + context: AutomationSearchRowContext +): AutomationListSearchRowSource[] { + return rows.map((row) => ({ + key: row.key, + fields: buildAutomationSearchFields(row, context) + })) +} + +export function buildExternalAutomationSearchRowSources( + entries: readonly ExternalAutomationListEntry[] +): AutomationListSearchRowSource[] { + return entries.map((entry) => ({ + key: entry.key, + fields: buildExternalAutomationSearchFields(entry) + })) +} + +/** Single pass over the changed row set; the only place indexes are built. */ +export function buildAutomationListSearchRows( + sources: readonly AutomationListSearchRowSource[] +): AutomationListSearchRow[] { + return sources.map((source) => ({ + key: source.key, + index: buildAutomationListSearchIndex(source.fields) + })) +} + +/** Single pass over prebuilt indexes; no indexing work per keystroke. */ +export function matchAutomationListSearchRowKeys( + rows: readonly AutomationListSearchRow[], + activeQuery: string +): string[] { + const keys: string[] = [] + for (const row of rows) { + if (automationListSearchIndexMatches(row.index, activeQuery)) { + keys.push(row.key) + } + } + return keys +} + +/** Rebuild gate: identical content across a refresh tick keeps the existing indexes. */ +export function buildAutomationListSearchRowFingerprint( + sources: readonly AutomationListSearchRowSource[] +): string { + return buildAutomationListSearchFingerprint( + sources.map((source) => source.fields), + sources.map((source) => source.key) + ) +} diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts index 47f59faaba6..5e9e880726f 100644 --- a/src/renderer/src/components/automations/automation-list-search.test.ts +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' import { + AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, automationListSearchFieldsMatch, automationListSearchIndexMatches, @@ -118,6 +123,75 @@ describe('automation-list-search', () => { expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true) }) + it('caps the searchable prompt at the first 2,048 characters', () => { + // Design doc: the bound is a hard requirement, not a tuning knob. + expect(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS).toBe(2048) + }) + + it('matches workspace, agent, and host alongside name, project, and prompt', () => { + const fields = { + name: 'Auto PR assignment', + project: 'orca / main', + workspace: 'feature/login-retry', + agent: 'Claude Code', + host: 'build-box', + prompt: 'Assign reviewers for open PRs' + } + for (const query of ['assignment', 'ORCA', 'login-retry', 'claude', 'build-box', 'reviewers']) { + expect(automationListSearchFieldsMatch(fields, query)).toBe(true) + } + expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) + }) + + it('bounds every indexed field, so no axis grows with its source', () => { + const index = buildAutomationListSearchIndex({ + name: 'n'.repeat(10_000), + project: 'p'.repeat(10_000), + workspace: 'w'.repeat(10_000), + agent: 'a'.repeat(10_000), + host: 'h'.repeat(10_000), + prompt: 'x'.repeat(10_000) + }) + expect(index.name.length).toBe(AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS) + expect(index.project.length).toBe(AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS) + expect(index.workspace.length).toBe(AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS) + expect(index.agent.length).toBe(AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS) + expect(index.host.length).toBe(AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS) + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + }) + + it('leaves absent workspace/agent/host axes empty rather than matching everything', () => { + const index = buildAutomationListSearchIndex({ name: 'Job', project: 'orca', prompt: 'hi' }) + expect(index.workspace).toBe('') + expect(index.agent).toBe('') + expect(index.host).toBe('') + expect(automationListSearchIndexMatches(index, 'anything')).toBe(false) + }) + + it('fingerprints the new axes and caps the prompt before hashing', () => { + const base = { name: 'A', project: 'p1', workspace: 'w1', agent: 'g1', host: 'h1', prompt: 'x' } + for (const field of ['workspace', 'agent', 'host'] as const) { + expect(buildAutomationListSearchFingerprint([base])).not.toBe( + buildAutomationListSearchFingerprint([{ ...base, [field]: 'changed' }]) + ) + } + // Two prompts differing only past the cap are one row set as far as search is concerned. + const prefix = 'y'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(buildAutomationListSearchFingerprint([{ ...base, prompt: `${prefix}tail-a` }])).toBe( + buildAutomationListSearchFingerprint([{ ...base, prompt: `${prefix}tail-b` }]) + ) + }) + + it('separates row identity from content so a reorder changes the fingerprint', () => { + const rows = [ + { name: 'A', project: 'p', prompt: 'one' }, + { name: 'B', project: 'p', prompt: 'two' } + ] + expect(buildAutomationListSearchFingerprint(rows, ['a', 'b'])).not.toBe( + buildAutomationListSearchFingerprint(rows, ['b', 'a']) + ) + }) + it('matches name, project, or prompt', () => { const fields = { name: 'Auto PR assignment', diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts index b27da8fe221..68df699c0c0 100644 --- a/src/renderer/src/components/automations/automation-list-search.ts +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -1,13 +1,26 @@ -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { + getActiveAutomationListSearchQuery, + resolveAutomationListSearchQuery +} from './automation-list-search-query' -/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ -export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 +export { + AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + clampAutomationListSearchQueryInput, + getActiveAutomationListSearchQuery, + isAutomationListSearchQueryTooLarge, + resolveAutomationListSearchQuery, + type AutomationListSearchQueryResolution +} from './automation-list-search-query' // Why: prompts can be multi-MB agent instructions. Index only a fixed prefix so // lowercasing/includes stay O(bound) per automation rather than O(prompt size). export const AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS = 512 export const AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS = 1_024 -export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 8 * 1024 +export const AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS = 512 +export const AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS = 128 +export const AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS = 256 +/** Design doc: at most the first 2,048 prompt characters are searchable. */ +export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 2_048 /** Indexed when a local automation has no resolved project so "unknown" still matches. */ export const AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT = 'unknown project' @@ -16,66 +29,30 @@ export type AutomationListSearchFields = { name: string project: string prompt: string + workspace?: string | null + agent?: string | null + host?: string | null } /** Lowercased, length-capped fields ready for substring match. */ export type AutomationListSearchIndex = { name: string project: string + workspace: string + agent: string + host: string prompt: string } -export type AutomationListSearchQueryResolution = - | { status: 'inactive' } - | { status: 'too_large' } - | { status: 'active'; query: string } - -export function isAutomationListSearchQueryTooLarge( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) -} - -/** - * Caps the controlled input value so a multi-MB paste cannot pin renderer - * memory. Keeping maxBytes+1 code units is enough for the over-limit check - * (`length > maxBytes`) while discarding the rest of the paste. - */ -export function clampAutomationListSearchQueryInput( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): string { - const maxStoredCodeUnits = maxBytes + 1 - if (rawQuery.length <= maxStoredCodeUnits) { - return rawQuery - } - return rawQuery.slice(0, maxStoredCodeUnits) -} - -export function resolveAutomationListSearchQuery( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): AutomationListSearchQueryResolution { - // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. - if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { - return { status: 'too_large' } - } - const query = rawQuery.trim().toLowerCase() - if (!query) { - return { status: 'inactive' } - } - return { status: 'active', query } -} - -/** Active lowercase query, or null when search must not run. */ -export function getActiveAutomationListSearchQuery( - rawQuery: string, - maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES -): string | null { - const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) - return resolved.status === 'active' ? resolved.query : null -} +/** Cheapest field first so the prompt prefix is scanned least often. */ +const SEARCH_FIELD_CAPS: readonly (readonly [keyof AutomationListSearchIndex, number])[] = [ + ['name', AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS], + ['agent', AUTOMATION_LIST_SEARCH_AGENT_MAX_CODE_UNITS], + ['host', AUTOMATION_LIST_SEARCH_HOST_MAX_CODE_UNITS], + ['workspace', AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS], + ['project', AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS], + ['prompt', AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS] +] /** Avoid splitting a surrogate pair at the cap boundary. */ export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string { @@ -104,20 +81,11 @@ export function normalizeAutomationListSearchField( export function buildAutomationListSearchIndex( fields: AutomationListSearchFields ): AutomationListSearchIndex { - return { - name: normalizeAutomationListSearchField( - fields.name, - AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS - ), - project: normalizeAutomationListSearchField( - fields.project, - AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS - ), - prompt: normalizeAutomationListSearchField( - fields.prompt, - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS - ) + const index = {} as AutomationListSearchIndex + for (const [field, maxCodeUnits] of SEARCH_FIELD_CAPS) { + index[field] = normalizeAutomationListSearchField(fields[field], maxCodeUnits) } + return index } export function buildAutomationProjectSearchText(parts: { @@ -135,12 +103,12 @@ export function automationListSearchIndexMatches( index: AutomationListSearchIndex, activeQuery: string ): boolean { - // Why: check short fields first so huge-prompt includes rarely run. - return ( - index.name.includes(activeQuery) || - index.project.includes(activeQuery) || - index.prompt.includes(activeQuery) - ) + for (const [field] of SEARCH_FIELD_CAPS) { + if (index[field].includes(activeQuery)) { + return true + } + } + return false } export function automationListSearchFieldsMatch( @@ -221,14 +189,13 @@ export function filterByAutomationListSearch( /** * Content fingerprint for search-relevant fields only. Used so list refresh * ticks that replace arrays with equivalent search content do not rebuild - * indexes or re-run filtering. + * indexes or re-run filtering. Fields are capped first so fingerprinting a + * multi-MB prompt costs the same as indexing it. */ export function buildAutomationListSearchFingerprint( - sources: readonly AutomationListSearchFields[] + sources: readonly AutomationListSearchFields[], + keys?: readonly string[] ): string { - if (sources.length === 0) { - return '' - } let fingerprint = '' for (let i = 0; i < sources.length; i += 1) { if (i > 0) { @@ -238,7 +205,10 @@ export function buildAutomationListSearchFingerprint( if (!source) { continue } - fingerprint += `${source.name}\u0001${source.project}\u0001${source.prompt}` + fingerprint += keys?.[i] ?? '' + for (const [field, maxCodeUnits] of SEARCH_FIELD_CAPS) { + fingerprint += `\u0001${truncateAutomationListSearchField(source[field] ?? '', maxCodeUnits)}` + } } return fingerprint } diff --git a/src/renderer/src/components/automations/automation-owner-action-runner.ts b/src/renderer/src/components/automations/automation-owner-action-runner.ts new file mode 100644 index 00000000000..b25baa16d08 --- /dev/null +++ b/src/renderer/src/components/automations/automation-owner-action-runner.ts @@ -0,0 +1,230 @@ +/** + * Runs one action against a row's captured owner and reports the result as + * data. + * + * Owner conflicts are ordinary outcomes here, not exceptions: the host changed, + * the target is gone, the server is too old to fence, or the chosen destination + * no longer resolves. Each one has something the user can do next, so each is + * returned with the recovery action the host-status vocabulary already defines + * rather than thrown into a generic failure toast. + * + * Nothing in this module reads ambient state. The availability it is handed was + * computed from the row the user acted on, and every request is fenced with it. + */ + +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import { + automationOwnerConflictMessage, + stripAutomationOwnerConflictCode, + type AutomationOwnerConflictCode +} from '../../../../shared/automation-owner-conflict' +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationDestination } from '../../../../shared/automation-owner-precondition' +import { translate } from '@/i18n/i18n' +import type { + AutomationActionAvailability, + AutomationActionBlock +} from './automation-captured-owner' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import { + AutomationHostScopeUnsupportedError, + createAutomationForDestination, + deleteAutomationForOwner, + deleteOrphanAutomation, + listAutomationRunsForOwner, + listAutomationsForOwner, + listOrphanAutomationRuns, + matchAutomationOwnerConflict, + runAutomationNowForOwner, + updateAutomationForOwner, + updateOrphanAutomation +} from './automation-scoped-list-client' + +export type AutomationOwnerConflict = { + code: AutomationOwnerConflictCode | 'unsupported_host_scope' + message: string + /** Null when no offered action would help — re-adding a removed host is manual. */ + recovery: AutomationHostRecoveryAction | null +} + +export type AutomationActionOutcome = + | { status: 'ok'; value: TValue } + | { status: 'blocked'; block: AutomationActionBlock } + | { status: 'conflict'; conflict: AutomationOwnerConflict } + | { status: 'failed'; message: string } + +const CONFLICT_RECOVERY: Record = + { + automation_owner_changed: 'retry', + // The host was deregistered; nothing the page can retry or reconnect brings it back. + automation_target_removed: null, + automation_owner_fencing_required: 'update-server', + automation_destination_invalid: 'retry' + } + +function conflictOutcome(code: AutomationOwnerConflictCode): AutomationActionOutcome { + return { + status: 'conflict', + conflict: { + code, + message: automationOwnerConflictMessage(code), + recovery: CONFLICT_RECOVERY[code] + } + } +} + +function classify(error: unknown): AutomationActionOutcome { + const conflict = matchAutomationOwnerConflict(error) + if (conflict) { + return conflictOutcome(conflict) + } + if (error instanceof AutomationHostScopeUnsupportedError) { + return { + status: 'conflict', + conflict: { + code: 'unsupported_host_scope', + message: stripAutomationOwnerConflictCode(error.message), + recovery: 'update-server' + } + } + } + return { + status: 'failed', + message: + error instanceof Error + ? stripAutomationOwnerConflictCode(error.message) + : translate( + 'auto.components.automations.ownerAction.failed', + 'That automation action did not complete.' + ) + } +} + +/** + * The captured owner decides whether the request is made at all, so a blocked + * row never reaches the transport and an uncaptured one is handed back to the + * caller's legacy path instead of being run against a guessed owner. + */ +async function attempt( + availability: AutomationActionAvailability, + owned: (owner: AutomationOwnerRef) => Promise, + orphanFenced?: () => Promise +): Promise | { status: 'uncaptured' }> { + if (availability.kind === 'blocked') { + return { status: 'blocked', block: availability.block } + } + if (availability.kind === 'uncaptured') { + return { status: 'uncaptured' } + } + try { + if (availability.kind === 'owned') { + return { status: 'ok', value: await owned(availability.owner) } + } + if (!orphanFenced) { + return { status: 'blocked', block: orphanUnsupported() } + } + return { status: 'ok', value: await orphanFenced() } + } catch (error) { + return classify(error) + } +} + +function orphanUnsupported(): AutomationActionBlock { + return { + reason: 'orphan', + message: translate( + 'auto.components.automations.capturedOwner.orphan', + 'This automation has no host to run on. Delete it, or re-add the host it belongs to.' + ), + recovery: null + } +} + +export type AutomationActionResult = + | AutomationActionOutcome + /** The caller must fall back to its unfenced path; Step 9 deletes this arm. */ + | { status: 'uncaptured' } + +export async function updateOwnedAutomation( + availability: AutomationActionAvailability, + authority: AutomationAuthorityRef, + id: string, + updates: AutomationUpdateInput, + destination?: AutomationDestination +): Promise> { + return await attempt( + availability, + (owner) => updateAutomationForOwner(owner, id, updates, destination), + () => updateOrphanAutomation(authority, id, updates) + ) +} + +export async function deleteOwnedAutomation( + availability: AutomationActionAvailability, + authority: AutomationAuthorityRef, + id: string +): Promise> { + return await attempt( + availability, + (owner) => deleteAutomationForOwner(owner, id), + () => deleteOrphanAutomation(authority, id) + ) +} + +export async function runOwnedAutomationNow( + availability: AutomationActionAvailability, + id: string +): Promise> { + return await attempt(availability, (owner) => runAutomationNowForOwner(owner, id)) +} + +/** + * Edit hydration re-reads the record inside its captured scope, so a row that + * moved hosts since the list is reported as gone rather than silently replaced + * by another authority's record with the same ID. + */ +export async function showOwnedAutomation( + availability: AutomationActionAvailability, + id: string +): Promise> { + return await attempt(availability, async (owner) => { + const result = await listAutomationsForOwner(owner) + return result.automations.find((automation) => automation.id === id) ?? null + }) +} + +export async function listOwnedAutomationRuns( + availability: AutomationActionAvailability, + authority: AutomationAuthorityRef, + automationId: string +): Promise> { + return await attempt( + availability, + (owner) => listAutomationRunsForOwner(owner, automationId), + () => listOrphanAutomationRuns(authority, automationId) + ) +} + +/** Create is destination-keyed rather than owner-keyed: the row does not exist yet. */ +export async function createAutomationAtDestination( + authority: AutomationAuthorityRef, + input: AutomationCreateInput, + destination: AutomationDestination +): Promise> { + try { + return { + status: 'ok', + value: await createAutomationForDestination(authority, input, destination) + } + } catch (error) { + return classify(error) + } +} diff --git a/src/renderer/src/components/automations/automation-row-action-dispatch.ts b/src/renderer/src/components/automations/automation-row-action-dispatch.ts new file mode 100644 index 00000000000..cf8a619abbd --- /dev/null +++ b/src/renderer/src/components/automations/automation-row-action-dispatch.ts @@ -0,0 +1,191 @@ +/** + * One place where a row action decides between its fenced path and its legacy + * one, so the page's handlers stay three lines each. + * + * The order is deliberate. A captured owner always wins. A row the authority + * qualified but could not make executable is refused with the reason. Only a + * row with no owner metadata at all falls back to the unfenced call, and that + * arm exists solely so mixed-version clients keep working until the inference + * is removed — it is the legacy path, not a default. + */ + +import type { + Automation, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { + automationActionAvailability, + capturedAutomationOwner, + type AutomationCapturedOwner, + type AutomationRowAction +} from './automation-captured-owner' +import { + deleteOwnedAutomation, + listOwnedAutomationRuns, + runOwnedAutomationNow, + showOwnedAutomation, + updateOwnedAutomation, + type AutomationActionResult +} from './automation-owner-action-runner' +import { actionBlockNotice, ownerConflictNotice } from './AutomationOwnerConflictNotice' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' + +export type AutomationActionNotice = { + message: string + recovery: AutomationHostRecoveryAction | null + /** + * `owner` means the request was refused because of who owns the record, so + * the caller must not proceed with a stale copy; `failure` is an ordinary + * error the caller may still degrade around. + */ + severity: 'owner' | 'failure' +} + +export type AutomationDispatchContext = { + /** Keyed by `AutomationListRow.key`; see {@link AutomationDispatchRow}. */ + capturedOwners: ReadonlyMap + authority: AutomationAuthorityRef +} + +/** + * Which copy of a record an action addresses. Both halves are load-bearing and + * neither derives the other: the row key picks the owner out of a list that may + * hold the same ID twice, and the ID is what that owner's authority stores it + * under. + */ +export type AutomationDispatchRow = { + rowKey: string + automationId: string +} + +export type AutomationDispatchResult = + | { ok: true; value: TValue } + | { ok: false; notice: AutomationActionNotice } + +export function dispatched(value: TValue): AutomationDispatchResult { + return { ok: true, value } +} + +export function toDispatchResult( + outcome: Exclude, { status: 'uncaptured' }> +): AutomationDispatchResult { + switch (outcome.status) { + case 'ok': + return { ok: true, value: outcome.value } + case 'blocked': + return { ok: false, notice: { ...actionBlockNotice(outcome.block), severity: 'owner' } } + case 'conflict': + return { ok: false, notice: { ...ownerConflictNotice(outcome.conflict), severity: 'owner' } } + case 'failed': + return { + ok: false, + notice: { message: outcome.message, recovery: 'retry', severity: 'failure' } + } + } +} + +async function dispatch( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + action: AutomationRowAction, + fenced: ( + availability: ReturnType + ) => Promise>, + legacy: () => Promise +): Promise> { + const availability = automationActionAvailability( + capturedAutomationOwner(context.capturedOwners, row.rowKey), + action + ) + const outcome = await fenced(availability) + if (outcome.status !== 'uncaptured') { + return toDispatchResult(outcome) + } + try { + return { ok: true, value: await legacy() } + } catch (error) { + return { + ok: false, + notice: { + message: error instanceof Error ? error.message : String(error), + recovery: 'retry', + severity: 'failure' + } + } + } +} + +export async function dispatchAutomationUpdate( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + updates: AutomationUpdateInput, + legacy: () => Promise, + action: AutomationRowAction = 'toggle' +): Promise> { + return await dispatch( + context, + row, + action, + (availability) => + updateOwnedAutomation(availability, context.authority, row.automationId, updates), + legacy + ) +} + +export async function dispatchAutomationDelete( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'delete', + (availability) => deleteOwnedAutomation(availability, context.authority, row.automationId), + legacy + ) +} + +export async function dispatchAutomationRunNow( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'run', + (availability) => runOwnedAutomationNow(availability, row.automationId), + legacy + ) +} + +export async function dispatchAutomationReread( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'edit', + (availability) => showOwnedAutomation(availability, row.automationId), + legacy + ) +} + +export async function dispatchAutomationRunHistory( + context: AutomationDispatchContext, + row: AutomationDispatchRow, + legacy: () => Promise +): Promise> { + return await dispatch( + context, + row, + 'history', + (availability) => listOwnedAutomationRuns(availability, context.authority, row.automationId), + legacy + ) +} diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts deleted file mode 100644 index f46426337a8..00000000000 --- a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AutomationRun } from '../../../../shared/automations-types' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' - -const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' - -function run(): AutomationRun { - return { - id: 'run-1', - automationId: 'automation-1', - title: 'Run 1', - scheduledFor: 1, - status: 'dispatched', - trigger: 'manual', - workspaceId: 'wt-1', - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: 'tab-1', - terminalPaneKey: PANE, - terminalPtyId: 'pty-1', - outputSnapshot: null, - precheckResult: null, - usage: null, - error: null, - startedAt: 1_000, - dispatchedAt: 1_000, - createdAt: 1_000 - } -} - -function entry(overrides: Partial = {}): AgentStatusEntry { - return { - paneKey: PANE, - state: 'done', - prompt: '', - updatedAt: 2_000, - stateStartedAt: 2_000, - stateHistory: [], - agentType: 'claude', - ...overrides - } -} - -function retained(status: AgentStatusEntry): RetainedAgentEntry { - return { - entry: status, - worktreeId: 'wt-1', - tab: { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId: 'wt-1', - title: 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1_000 - }, - agentType: 'claude', - startedAt: status.stateStartedAt - } -} - -describe('automation run completion evidence (STA-3386)', () => { - it('ignores live and retained session-boundary done rows', () => { - const boundary = entry({ sessionBoundary: true }) - expect( - hasAutomationRunCompletionEvidence({ - run: run(), - dispatchedAt: 1_000, - agentStatusByPaneKey: { [PANE]: boundary }, - retainedAgentsByPaneKey: { [PANE]: retained(boundary) } - }) - ).toBe(false) - }) - - it('accepts a real done after dispatch', () => { - expect( - hasAutomationRunCompletionEvidence({ - run: run(), - dispatchedAt: 1_000, - agentStatusByPaneKey: { [PANE]: entry() }, - retainedAgentsByPaneKey: {} - }) - ).toBe(true) - }) -}) diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.ts deleted file mode 100644 index 26c0b0ffc93..00000000000 --- a/src/renderer/src/components/automations/automation-run-completion-evidence.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AutomationRun } from '../../../../shared/automations-types' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { RetainedAgentEntry } from '@/store/slices/agent-status' -import { automationRunMatchesPaneKey } from './automation-run-open-target' - -function isCompletionAfterDispatch(entry: AgentStatusEntry, dispatchedAt: number): boolean { - return entry.state === 'done' && entry.sessionBoundary !== true && entry.updatedAt >= dispatchedAt -} - -export function hasAutomationRunCompletionEvidence({ - run, - dispatchedAt, - agentStatusByPaneKey, - retainedAgentsByPaneKey -}: { - run: AutomationRun - dispatchedAt: number - agentStatusByPaneKey: Record - retainedAgentsByPaneKey: Record -}): boolean { - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - if ( - automationRunMatchesPaneKey(run, paneKey) && - isCompletionAfterDispatch(entry, dispatchedAt) - ) { - return true - } - } - for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) { - if ( - automationRunMatchesPaneKey(run, paneKey) && - isCompletionAfterDispatch(retained.entry, dispatchedAt) - ) { - return true - } - } - return false -} diff --git a/src/renderer/src/components/automations/automation-run-occurrences.ts b/src/renderer/src/components/automations/automation-run-occurrences.ts new file mode 100644 index 00000000000..71af9043eb8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-occurrences.ts @@ -0,0 +1,35 @@ +/** + * Copy for a run row that stands for more than one occurrence. + * + * Coalescing replaced a hundred identical refusals with one row, but that row is + * stamped with the *first* occurrence — so a failure that is still happening + * every hour reads as one thing that happened once, days ago. The count answers + * how bad; the latest timestamp answers whether it is over. + */ + +import type { AutomationRun } from '../../../../shared/automations-types' +import { formatAutomationDateTime } from './automation-page-parts' +import { translate } from '@/i18n/i18n' + +type AutomationRunOccurrences = Pick + +/** Null for the single-occurrence rows, which is every row written before folding. */ +export function automationRunOccurrenceLabel(run: AutomationRunOccurrences): string | null { + const count = run.occurrenceCount ?? 1 + if (count <= 1) { + return null + } + // Not named `count`: i18next reserves it for plural selection, which would send + // these keys looking for `_one`/`_other` variants the catalog does not carry. + // The label only renders above 1, so the plural is always right. + const times = String(count) + // The writer stamps both fields together; a count without a timestamp can only + // come from an older host, and the count alone still beats saying nothing. + return run.lastOccurrenceAt + ? translate( + 'auto.components.automations.runHistory.occurrencesWithLatest', + '{{times}} times, most recently {{date}}', + { times, date: formatAutomationDateTime(run.lastOccurrenceAt) } + ) + : translate('auto.components.automations.runHistory.occurrences', '{{times}} times', { times }) +} diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts new file mode 100644 index 00000000000..50e0c5886d8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../../shared/automation-owner-conflict' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' + +const callRuntimeRpc = vi.fn() +const getRuntimeEnvironmentStatus = vi.fn() + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => getRuntimeEnvironmentStatus(...args), + // Why: the real matcher is exercised in runtime-rpc-result's own tests; here it only needs to be honest about the tail token. + hasRuntimeRpcErrorCode: (error: unknown, code: string) => + typeof (error as { message?: unknown })?.message === 'string' && + (error as { message: string }).message.trimEnd().endsWith(`: ${code}`) +})) + +const listScoped = vi.fn() +const update = vi.fn() +const remove = vi.fn() +const listRuns = vi.fn() + +const DESKTOP = { kind: 'desktop' } as const +const RUNTIME = { kind: 'runtime', environmentId: 'env-1', pairingRevision: 4 } as const +const SSH_OWNER = { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } +} as const + +const ALL_CAPABILITIES = { + capabilities: [ + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + ] +} + +beforeEach(() => { + callRuntimeRpc.mockReset() + getRuntimeEnvironmentStatus.mockReset() + listScoped.mockReset() + update.mockReset() + remove.mockReset() + listRuns.mockReset() + vi.stubGlobal('window', { + api: { automations: { listScoped, update, delete: remove, listRuns } } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function client() { + return await import('./automation-scoped-list-client') +} + +describe('listScopedAutomations', () => { + it('rejects a legacy-shaped payload instead of committing it as one host', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + listScoped.mockResolvedValue({ automations: [{ id: 'a1' }] }) + await expect(listScopedAutomations(DESKTOP, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + }) + + it('rejects a structurally broken payload', async () => { + const { listScopedAutomations, AutomationListResponseError } = await client() + listScoped.mockResolvedValue({ automations: 'nope' }) + await expect(listScopedAutomations(DESKTOP, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationListResponseError + ) + }) + + it('drops rows the host scoped elsewhere and keeps the rest', async () => { + const { listScopedAutomations } = await client() + listScoped.mockResolvedValue({ + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'other', targetGeneration: 1 } }, + { automationId: 'a2', selector: { kind: 'self' } } + ], + orphanCount: 2 + }) + const result = await listScopedAutomations(DESKTOP, { kind: 'self' }) + expect(result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(result.invalidRows).toBe(1) + expect(result.orphanCount).toBe(2) + }) + + it('negotiates host scope and pins the request to the captured pairing revision', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(RUNTIME, { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 7 + }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledWith('env-1', expect.any(Number)) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.list', + { selector: { kind: 'ssh', targetId: 'ssh-1', expectedTargetGeneration: 7 } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) + + it('surfaces a stale pairing revision instead of retrying unfenced', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockRejectedValue(new Error('runtime_environment_revision_changed')) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toThrow( + 'runtime_environment_revision_changed' + ) + }) + + it('does not query a host that never advertised host scope', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + // An unreachable host proves nothing about its version, so it must not be reported as too old. + it('propagates an unreachable authority instead of calling it incompatible', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockRejectedValue(new Error('runtime_unavailable')) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.not.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + }) +}) + +describe('owner-fenced mutations', () => { + it('sends the captured owner with the mutation', async () => { + const { updateAutomationForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) + await updateAutomationForOwner(SSH_OWNER, 'a1', { enabled: false }) + expect(callRuntimeRpc.mock.calls[0]?.[2]).toEqual({ + id: 'a1', + updates: { enabled: false }, + expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + destination: undefined + }) + }) + + it('stays view-only against a host without owner fencing', async () => { + const { updateAutomationForOwner, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + await expect( + updateAutomationForOwner(SSH_OWNER, 'a1', { enabled: false }) + ).rejects.toBeInstanceOf(AutomationHostScopeUnsupportedError) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('fences a desktop mutation through IPC with the same precondition', async () => { + const { deleteAutomationForOwner, updateAutomationForOwner } = await client() + update.mockResolvedValue({ id: 'a1' }) + await updateAutomationForOwner({ authority: DESKTOP, selector: { kind: 'self' } }, 'a1', { + enabled: true + }) + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ expectedOwner: { selector: { kind: 'self' } } }) + ) + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + expect(typeof deleteAutomationForOwner).toBe('function') + }) +}) + +// The orphan arm used to live in the caller with its own transport choice and no +// capability probe. These pin it to the owned arm's behaviour so the two cannot +// drift apart again. +describe('orphan-fenced mutations', () => { + it('fences on the orphan precondition over the same runtime transport', async () => { + const { deleteOrphanAutomation } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue(undefined) + await deleteOrphanAutomation(RUNTIME, 'a1') + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.delete', + { id: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) + + it('probes the host before an orphan mutation, exactly as an owned row does', async () => { + const { updateOrphanAutomation, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue({ + capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] + }) + await expect(updateOrphanAutomation(RUNTIME, 'a1', { enabled: false })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + // An unreachable host is a retry, not an upgrade prompt — the orphan arm inherits this. + it('propagates an unreachable authority instead of calling it incompatible', async () => { + const { deleteOrphanAutomation, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockRejectedValue(new Error('runtime_unavailable')) + await expect(deleteOrphanAutomation(RUNTIME, 'a1')).rejects.not.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + }) + + it('routes a desktop orphan through IPC rather than a local RPC call', async () => { + const { deleteOrphanAutomation, updateOrphanAutomation } = await client() + update.mockResolvedValue({ id: 'a1' }) + await updateOrphanAutomation(DESKTOP, 'a1', { enabled: false }) + await deleteOrphanAutomation(DESKTOP, 'a1') + expect(update).toHaveBeenCalledWith({ + id: 'a1', + updates: { enabled: false }, + expectedOwner: { selector: { kind: 'orphan' } }, + // An orphan has no host to be moved to, so no destination is ever sent. + destination: undefined + }) + expect(remove).toHaveBeenCalledWith({ + id: 'a1', + expectedOwner: { selector: { kind: 'orphan' } } + }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() + }) + + // History is the one action an orphan keeps (ORPHAN_ACTIONS), so it must reach + // the transport rather than resolving as "no host to run on". + it('reads an orphan run history over the runtime transport', async () => { + const { listOrphanAutomationRuns } = await client() + callRuntimeRpc.mockResolvedValue({ runs: [{ id: 'r1' }] }) + const runs = await listOrphanAutomationRuns(RUNTIME, 'a1') + expect(runs).toEqual([{ id: 'r1' }]) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'automation.runs', + { automationId: 'a1', expectedOwner: { selector: { kind: 'orphan' } } }, + expect.objectContaining({ expectedEnvironmentPairingRevision: 4 }) + ) + }) + + it('reads a desktop orphan history through IPC', async () => { + const { listOrphanAutomationRuns } = await client() + listRuns.mockResolvedValue([]) + await listOrphanAutomationRuns(DESKTOP, 'a1') + expect(listRuns).toHaveBeenCalledWith({ + automationId: 'a1', + expectedOwner: { selector: { kind: 'orphan' } } + }) + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + // Read-only, so it deliberately takes the owned read's probe behaviour — which + // today is no probe. Pinned so the two arms cannot diverge silently. + it('probes on the orphan history read exactly as often as the owned read does', async () => { + const { listAutomationRunsForOwner, listOrphanAutomationRuns } = await client() + callRuntimeRpc.mockResolvedValue({ runs: [] }) + await listAutomationRunsForOwner(SSH_OWNER, 'a1') + const ownedProbes = getRuntimeEnvironmentStatus.mock.calls.length + getRuntimeEnvironmentStatus.mockClear() + await listOrphanAutomationRuns(RUNTIME, 'a1') + expect(getRuntimeEnvironmentStatus.mock.calls.length).toBe(ownedProbes) + }) + + it('cannot be handed an owner precondition — the fence is not a parameter', async () => { + const { updateOrphanAutomation, ORPHAN_OWNER_PRECONDITION } = await client() + update.mockResolvedValue({ id: 'a1' }) + await updateOrphanAutomation(DESKTOP, 'a1', { enabled: false }) + expect(update.mock.calls[0]?.[0]?.expectedOwner).toEqual(ORPHAN_OWNER_PRECONDITION) + expect(updateOrphanAutomation.length).toBe(3) + }) +}) + +describe('matchAutomationOwnerConflict', () => { + it('classifies a conflict rewrapped by Electron IPC', async () => { + const { matchAutomationOwnerConflict } = await client() + const wrapped = new Error( + `Error invoking remote method 'automations:update': Error: This automation's host changed. Reload it before continuing.: ${AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged}` + ) + expect(matchAutomationOwnerConflict(wrapped)).toBe(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + }) + + it('classifies a structured error code and ignores unrelated failures', async () => { + const { matchAutomationOwnerConflict } = await client() + expect( + matchAutomationOwnerConflict({ code: AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved }) + ).toBe(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved) + expect(matchAutomationOwnerConflict(new Error('timeout'))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts new file mode 100644 index 00000000000..6eec4ce430e --- /dev/null +++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts @@ -0,0 +1,357 @@ +/** + * Owner-qualified automation requests for one authority. + * + * Every call carries the incarnation the caller captured: a runtime request is + * pinned to its `pairingRevision` through the existing environment revision + * guard, and an SSH-scoped request carries the registration generation the row + * was fetched under. Responses are validated, never cast — an older host that + * silently drops the selector answers with its whole authority, and committing + * that would attribute other hosts' automations to the selected one. + */ + +import { + callRuntimeRpc, + getRuntimeEnvironmentStatus, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput +} from '../../../../shared/automations-types' +import type { + AutomationListResult, + AutomationListScopeSelector +} from '../../../../shared/automation-list-scope' +import { validateAutomationListResponse } from '../../../../shared/automation-list-response' +import type { + AutomationAuthorityRef, + AutomationOwnerRef +} from '../../../../shared/automation-owner-ref' +/** The one classifier every client shares; re-exported so call sites keep importing it from here. */ +export { matchAutomationOwnerConflict } from '../../../../shared/automation-owner-conflict' +import type { + AutomationDestination, + AutomationOwnerPrecondition +} from '../../../../shared/automation-owner-precondition' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { automationHostDiagnostics } from './automation-host-diagnostics' + +const REQUEST_TIMEOUT_MS = 15_000 + +export class AutomationHostScopeUnsupportedError extends Error { + readonly code = 'unsupported_host_scope' + + constructor(message: string) { + super(message) + this.name = 'AutomationHostScopeUnsupportedError' + } +} + +export class AutomationListResponseError extends Error { + readonly code = 'invalid_response' + + constructor(message: string) { + super(message) + this.name = 'AutomationListResponseError' + } +} + +export type ScopedAutomationList = AutomationListResult & { + /** Rows dropped because their metadata was missing, duplicated, or scoped elsewhere. */ + invalidRows: number +} + +function runtimeTarget(authority: AutomationAuthorityRef): RuntimeClientTarget { + return authority.kind === 'desktop' + ? { kind: 'local' } + : { kind: 'environment', environmentId: authority.environmentId } +} + +function scopeSelector(owner: AutomationOwnerRef): AutomationListScopeSelector { + return owner.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: owner.selector.targetId, + expectedTargetGeneration: owner.selector.targetGeneration + } + : { kind: 'self' } +} + +/** Orphan rows have a known authority and no executable owner, so delete/pause fence on that instead. */ +export const ORPHAN_OWNER_PRECONDITION: AutomationOwnerPrecondition = { + selector: { kind: 'orphan' } +} + +export function ownerPrecondition(owner: AutomationOwnerRef): AutomationOwnerPrecondition { + return { + selector: + owner.selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: owner.selector.targetId, + targetGeneration: owner.selector.targetGeneration + } + : { kind: 'self' } + } +} + +async function callAuthority( + authority: AutomationAuthorityRef, + method: string, + params: unknown +): Promise { + return await callRuntimeRpc(runtimeTarget(authority), method, params, { + timeoutMs: REQUEST_TIMEOUT_MS, + // Why: a same-id re-pair must invalidate this request instead of retargeting it. + ...(authority.kind === 'runtime' + ? { expectedEnvironmentPairingRevision: authority.pairingRevision } + : {}) + }) +} + +/** + * Fails closed on a missing capability, but only on a *known* absence: an + * unreachable authority must classify as unavailable and retry, not as an old + * server the user is told to upgrade. + * + * The probe is counted here because it is counted nowhere else: it deliberately + * re-fetches on every call and rides outside the scheduler's four-slot pool, so + * an instrument that saw only pooled work would report half the relay traffic a + * 50-host refresh actually costs. + */ +async function assertAuthorityCapability( + authority: AutomationAuthorityRef, + capability: RuntimeCapability, + message: string +): Promise { + if (authority.kind !== 'runtime') { + return + } + automationHostDiagnostics.recordCapabilityProbe({ + authorityKey: automationAuthorityCatalogKey(authority) + }) + const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) + if (!status.capabilities?.includes(capability)) { + throw new AutomationHostScopeUnsupportedError(message) + } +} + +async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise { + await assertAuthorityCapability( + authority, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE + ) +} + +function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList { + const validation = validateAutomationListResponse(raw, selector) + if (!validation.ok) { + throw validation.error.code === 'unsupported_host_scope' + ? new AutomationHostScopeUnsupportedError(validation.error.message) + : new AutomationListResponseError(validation.error.message) + } + return { ...validation.result, invalidRows: validation.invalidRows } +} + +export async function listScopedAutomations( + authority: AutomationAuthorityRef, + selector: AutomationListScopeSelector +): Promise { + if (authority.kind === 'desktop') { + return validated(await window.api.automations.listScoped({ selector }), selector) + } + await assertAuthorityCapability( + authority, + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE + ) + return validated(await callAuthority(authority, 'automation.list', { selector }), selector) +} + +/** + * The one unscoped request an old runtime gets per refresh cycle. Its result is + * partitioned client-side into every requested entry; it carries no owners and + * no usage projection, so those rows stay view-only. + */ +export async function listLegacyAutomations( + authority: AutomationAuthorityRef +): Promise { + if (authority.kind === 'desktop') { + // Desktop storage ships the scoped contract in-process, so it never degrades. + throw new AutomationListResponseError('The desktop authority always supports scoped lists.') + } + const raw = await callAuthority(authority, 'automation.list', null) + const automations = (raw as { automations?: unknown } | null)?.automations + if (!Array.isArray(automations)) { + throw new AutomationListResponseError('The host returned an unreadable automation list.') + } + return automations as Automation[] +} + +/** Convenience wrapper for a row's own host; orphan scopes are requested with the selector form. */ +export async function listAutomationsForOwner( + owner: AutomationOwnerRef +): Promise { + return await listScopedAutomations(owner.authority, scopeSelector(owner)) +} + +/** + * The only place that chooses IPC or RPC for a fenced run history read. + * + * Deliberately unprobed, matching the mutation arms' *absence* of a probe here: + * history is read-only, and an older host that ignores the precondition answers + * with the rows it has rather than acting on a fence it never honoured. + */ +async function listRunsFenced( + authority: AutomationAuthorityRef, + automationId: string, + expectedOwner: AutomationOwnerPrecondition +): Promise { + if (authority.kind === 'desktop') { + return await window.api.automations.listRuns({ automationId, expectedOwner }) + } + const result = await callAuthority<{ runs: AutomationRun[] }>(authority, 'automation.runs', { + automationId, + expectedOwner + }) + return result.runs +} + +export async function listAutomationRunsForOwner( + owner: AutomationOwnerRef, + automationId: string +): Promise { + return await listRunsFenced(owner.authority, automationId, ownerPrecondition(owner)) +} + +/** + * The only place that chooses IPC or RPC for a fenced mutation. Owned and orphan + * rows differ in the precondition they fence with and in nothing else, so they + * share the transport, the capability probe, and any check either later gains. + */ +async function updateFenced( + authority: AutomationAuthorityRef, + id: string, + updates: AutomationUpdateInput, + expectedOwner: AutomationOwnerPrecondition, + destination?: AutomationDestination +): Promise { + await assertOwnerFencingSupported(authority) + if (authority.kind === 'desktop') { + return await window.api.automations.update({ id, updates, expectedOwner, destination }) + } + const result = await callAuthority<{ automation: Automation }>(authority, 'automation.update', { + id, + updates, + expectedOwner, + destination + }) + return result.automation +} + +async function deleteFenced( + authority: AutomationAuthorityRef, + id: string, + expectedOwner: AutomationOwnerPrecondition +): Promise { + await assertOwnerFencingSupported(authority) + if (authority.kind === 'desktop') { + await window.api.automations.delete({ id, expectedOwner }) + return + } + await callAuthority(authority, 'automation.delete', { id, expectedOwner }) +} + +export async function updateAutomationForOwner( + owner: AutomationOwnerRef, + id: string, + updates: AutomationUpdateInput, + destination?: AutomationDestination +): Promise { + return await updateFenced(owner.authority, id, updates, ownerPrecondition(owner), destination) +} + +export async function deleteAutomationForOwner( + owner: AutomationOwnerRef, + id: string +): Promise { + await deleteFenced(owner.authority, id, ownerPrecondition(owner)) +} + +/** + * Orphan rows have no owner to key by, so the orphan precondition is the fence. + * The authority is still known and still probed: an orphan on an out-of-date + * host is refused for the same reason an owned row there is. + * + * No destination is accepted — moving a row needs a host it can move to. + */ +export async function updateOrphanAutomation( + authority: AutomationAuthorityRef, + id: string, + updates: AutomationUpdateInput +): Promise { + return await updateFenced(authority, id, updates, ORPHAN_OWNER_PRECONDITION) +} + +export async function deleteOrphanAutomation( + authority: AutomationAuthorityRef, + id: string +): Promise { + await deleteFenced(authority, id, ORPHAN_OWNER_PRECONDITION) +} + +/** + * An orphan keeps its history: reading past runs needs no host to run on, which + * is why `ORPHAN_ACTIONS` allows it where execution is blocked. + */ +export async function listOrphanAutomationRuns( + authority: AutomationAuthorityRef, + automationId: string +): Promise { + return await listRunsFenced(authority, automationId, ORPHAN_OWNER_PRECONDITION) +} + +export async function runAutomationNowForOwner( + owner: AutomationOwnerRef, + id: string +): Promise { + await assertOwnerFencingSupported(owner.authority) + const expectedOwner = ownerPrecondition(owner) + if (owner.authority.kind === 'desktop') { + return await window.api.automations.runNow({ id, expectedOwner }) + } + const result = await callAuthority<{ run: AutomationRun }>(owner.authority, 'automation.runNow', { + id, + expectedOwner + }) + return result.run +} + +export async function createAutomationForDestination( + authority: AutomationAuthorityRef, + input: AutomationCreateInput, + destination: AutomationDestination +): Promise { + await assertOwnerFencingSupported(authority) + if (authority.kind === 'desktop') { + return await window.api.automations.create(input, { destination }) + } + const { projectId, workspaceId, ...rest } = input + const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', { + ...rest, + repo: projectId, + workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined, + destination + }) + return result.automation +} diff --git a/src/renderer/src/components/automations/automation-usage-model.ts b/src/renderer/src/components/automations/automation-usage-model.ts index fcc43e0703e..19118884ed7 100644 --- a/src/renderer/src/components/automations/automation-usage-model.ts +++ b/src/renderer/src/components/automations/automation-usage-model.ts @@ -1,62 +1,9 @@ -import type { AutomationRun, AutomationRunUsage } from '../../../../shared/automations-types' +import type { AutomationRunUsage } from '../../../../shared/automations-types' -export type AutomationUsageSummary = { - knownRuns: number - unavailableRuns: number - inputTokens: number - outputTokens: number - cacheTokens: number - reasoningOutputTokens: number - totalTokens: number - estimatedCostUsd: number | null -} - -export function summarizeAutomationRunUsage( - runs: readonly AutomationRun[] -): AutomationUsageSummary { - let knownRuns = 0 - let unavailableRuns = 0 - let inputTokens = 0 - let outputTokens = 0 - let cacheTokens = 0 - let reasoningOutputTokens = 0 - let totalTokens = 0 - let estimatedCostUsd = 0 - let hasKnownCost = false - - for (const run of runs) { - const usage = run.usage - if (!usage) { - unavailableRuns++ - continue - } - if (usage.status !== 'known') { - unavailableRuns++ - continue - } - knownRuns++ - inputTokens += usage.inputTokens ?? 0 - outputTokens += usage.outputTokens ?? 0 - cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) - reasoningOutputTokens += usage.reasoningOutputTokens ?? 0 - totalTokens += usage.totalTokens ?? 0 - if (usage.estimatedCostUsd !== null) { - estimatedCostUsd += usage.estimatedCostUsd - hasKnownCost = true - } - } - - return { - knownRuns, - unavailableRuns, - inputTokens, - outputTokens, - cacheTokens, - reasoningOutputTokens, - totalTokens, - estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null - } -} +// Why: the summary is computed by whichever authority owns the retained runs, so +// it lives in shared code; only the display formatting below is renderer-only. +export type { AutomationUsageSummary } from '../../../../shared/automation-usage-summary' +export { summarizeAutomationRunUsage } from '../../../../shared/automation-usage-summary' export function formatAutomationTokens(value: number | null | undefined): string { if (!value) { diff --git a/src/renderer/src/components/automations/automation-write-invalidation.test.ts b/src/renderer/src/components/automations/automation-write-invalidation.test.ts new file mode 100644 index 00000000000..f1e3d80255a --- /dev/null +++ b/src/renderer/src/components/automations/automation-write-invalidation.test.ts @@ -0,0 +1,81 @@ +/** + * A write knows the host it addressed; these pin that it never widens that into + * another host's rows, and never narrows an unknown host into a guessed one. + */ + +import { describe, expect, it } from 'vitest' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import type { AutomationCapturedOwner } from './automation-captured-owner' +import { + automationRowCatalogRef, + automationWriteChangeEvent, + stableAutomationAuthorityRef +} from './automation-write-invalidation' + +const DESKTOP: AutomationAuthorityRef = { kind: 'desktop' } +const RUNTIME: AutomationAuthorityRef = { + kind: 'runtime', + environmentId: 'env-1', + pairingRevision: 7 +} + +describe('automation write invalidation', () => { + it('drops the incarnation from the authority', () => { + expect(stableAutomationAuthorityRef(RUNTIME)).toEqual({ + kind: 'runtime', + environmentId: 'env-1' + }) + expect(stableAutomationAuthorityRef(DESKTOP)).toEqual({ kind: 'desktop' }) + }) + + it('takes the host from the row owner, not the active authority', () => { + const captured: AutomationCapturedOwner = { + owner: { + authority: RUNTIME, + selector: { kind: 'ssh', targetId: 'target-1', targetGeneration: 3 } + }, + selector: { kind: 'ssh', targetId: 'target-1' } + } + + expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'target-1' } + }) + }) + + it('falls back to the captured selector under the acting authority', () => { + const captured: AutomationCapturedOwner = { + owner: null, + selector: { kind: 'orphan', issue: 'target-missing' } + } + + expect(automationRowCatalogRef(captured, DESKTOP)).toEqual({ + authority: { kind: 'desktop' }, + selector: { kind: 'orphan' } + }) + }) + + it('names no host for a row that carried no metadata', () => { + expect(automationRowCatalogRef({ owner: null, selector: null }, DESKTOP)).toBeNull() + }) + + it('widens to the whole authority rather than guessing a selector', () => { + expect(automationWriteChangeEvent(null, RUNTIME)).toEqual({ + authority: { kind: 'runtime', environmentId: 'env-1' }, + reason: 'definition' + }) + }) + + it('scopes the event to the host the write named', () => { + expect( + automationWriteChangeEvent( + { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + DESKTOP + ) + ).toEqual({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' }, + reason: 'definition' + }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-write-invalidation.ts b/src/renderer/src/components/automations/automation-write-invalidation.ts new file mode 100644 index 00000000000..751f237dcf2 --- /dev/null +++ b/src/renderer/src/components/automations/automation-write-invalidation.ts @@ -0,0 +1,77 @@ +/** + * Which host cache entry a local automation write must invalidate. + * + * The authority publishes its own `automationsChanged` event, but that is an IPC + * round trip the user spends looking at the list they just changed — and it is + * droppable, because the host a create lands on may have no cache entry yet. The + * write already knows the host it addressed, so it feeds the same invalidation + * in directly. A row whose owner was never captured names no host, and the whole + * authority is invalidated rather than a guessed selector. + */ + +import { toStableCatalogRef } from '../../../../shared/automation-owner-key' +import type { + AutomationAuthorityRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef +} from '../../../../shared/automation-owner-ref' +import type { AutomationCapturedOwner } from './automation-captured-owner' +import type { + AutomationAuthorityChangeEvent, + AutomationAuthorityChangeReason +} from './automation-host-invalidation' + +export function stableAutomationAuthorityRef( + authority: AutomationAuthorityRef +): StableAutomationAuthorityRef { + return authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: authority.environmentId } +} + +/** + * The host a listed row belongs to. Null when the row carried no owner metadata + * at all: an authority that never qualified its rows cannot be asked which of + * its hosts changed. + */ +export function automationRowCatalogRef( + captured: AutomationCapturedOwner, + authority: AutomationAuthorityRef +): StableAutomationCatalogRef | null { + if (captured.owner) { + return toStableCatalogRef(captured.owner) + } + if (!captured.selector) { + return null + } + const stable = stableAutomationAuthorityRef(authority) + // Rebuilt per variant rather than spread: the stable ref drops the generation and the + // orphan issue, and a widened `kind` would not discriminate. + switch (captured.selector.kind) { + case 'ssh': + return { + authority: stable, + selector: { kind: 'ssh', targetId: captured.selector.targetId } + } + case 'orphan': + return { authority: stable, selector: { kind: 'orphan' } } + case 'self': + return { authority: stable, selector: { kind: 'self' } } + } +} + +/** + * The invalidation a completed write owes, ready to feed to the host controller. + * The reason is the caller's: a run rewrites history and the next-run projection + * without touching the definition, and a subscriber that filters on it must not + * be told otherwise. + */ +export function automationWriteChangeEvent( + ref: StableAutomationCatalogRef | null, + authority: AutomationAuthorityRef, + reason: AutomationAuthorityChangeReason = 'definition' +): AutomationAuthorityChangeEvent { + return ref + ? { authority: ref.authority, selector: ref.selector, reason } + : { authority: stableAutomationAuthorityRef(authority), reason } +} diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts new file mode 100644 index 00000000000..85769bf20b0 --- /dev/null +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -0,0 +1,248 @@ +/** + * Fixtures for the automations page characterization tests. + * + * The automation defaults are deliberately the *runnable* shape — a local record + * whose repo has no SSH connection — so Run Now reaches its handler instead of + * being refused by the availability gate. Tests that need an unrunnable record + * override the fields they care about. + */ + +import type { + Automation, + AutomationRun, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { automationListRowKey, type AutomationListRow } from './automation-list-row-identity' +import { ALL_AUTOMATION_HOSTS_FILTER } from '../../../../shared/automation-host-filter' +import { getDefaultSettings } from '../../../../shared/constants' + +export const REPO_ID = 'repo-1' +export const WORKSPACE_ID = 'workspace-1' + +export function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'a-1', + name: 'Nightly', + prompt: 'Run the nightly sweep', + precheck: null, + agentId: 'claude', + runContext: null, + projectId: REPO_ID, + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +/** A list row for a record on a given host; the host defaults to the desktop. */ +export function makeAutomationListRow( + overrides: Partial & { hostStableKey?: string } = {} +): AutomationListRow { + const automation = overrides.automation ?? makeAutomation() + const hostStableKey = overrides.hostStableKey ?? 'host:desktop:self' + return { + key: overrides.key ?? automationListRowKey(hostStableKey, automation.id), + automation, + hostLabel: overrides.hostLabel ?? 'This computer', + usageSummary: overrides.usageSummary ?? null + } +} + +export function makeRun(overrides: Partial = {}): AutomationRun { + return { + id: 'run-1', + automationId: 'a-1', + title: 'Nightly #1', + scheduledFor: 10, + 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: 10, + dispatchedAt: 10, + createdAt: 10, + ...overrides + } +} + +export function makeExternalManager( + overrides: Partial = {} +): ExternalAutomationManager { + return { + id: 'hermes:local', + provider: 'hermes', + label: 'Hermes', + targetLabel: 'This computer', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [ + { + id: 'job-1', + managerId: 'hermes:local', + provider: 'hermes', + name: 'Hermes job', + schedule: 'Daily at 09:00', + rawSchedule: '0 9 * * *', + enabled: true, + state: 'active', + prompt: null, + promptPreview: 'Sweep', + nextRunAt: null, + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 0, + runs: [] + } + ], + ...overrides + } +} + +/** The desktop-self scope every manager fixture is discovered under by default. */ +export function makeExternalAutomationScope( + overrides: Partial = {} +): ExternalAutomationScope { + return { + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } + }, + provider: 'hermes', + ...overrides + } +} + +export function makeScopedExternalManager( + overrides: Partial = {}, + scope: ExternalAutomationScope = makeExternalAutomationScope() +): ScopedExternalAutomationManager { + return { scope, manager: makeExternalManager(overrides) } +} + +function makeRepo(): Repo { + return { + id: REPO_ID, + displayName: 'orca', + path: '/repos/orca', + badgeColor: '#000000', + addedAt: 1, + worktreeBaseRef: 'main' + } +} + +/** Ready and local, so the editor can build a run context and state a Self destination. */ +function makeProjectHostSetup(): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: REPO_ID, + path: '/repos/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } +} + +function makeWorktree(): Worktree { + return { + id: WORKSPACE_ID, + repoId: REPO_ID, + displayName: 'main', + path: '/repos/orca', + branch: 'main' + } as Worktree +} + +export type AutomationsPageStoreFixtures = { + state: Record + repoMap: Map + worktreeMap: Map +} + +/** A store shaped like the page's reads; actions are spies so nothing escapes the test. */ +export function makeStoreState(): AutomationsPageStoreFixtures { + const repo = makeRepo() + const worktree = makeWorktree() + const noop = (): void => undefined + return { + state: { + repos: [repo], + projectHostSetups: [makeProjectHostSetup()], + worktreesByRepo: { [REPO_ID]: [worktree] }, + unifiedTabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + activeWorktreeId: null, + fetchWorktrees: noop, + fetchAllWorktrees: noop, + startupWorktreeRefreshCompleted: true, + updateSettings: noop, + openSettingsPage: noop, + openSettingsTarget: noop, + closeAutomationsPage: noop, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + removedSshTargetLabels: new Map(), + // Hydrated with no targets: the desktop authority has looked and has none, + // so the catalog projects Desktop + Self and nothing else. + sshTargetsHydrated: true, + sshStateByEnvironment: new Map(), + runtimeEnvironments: [], + runtimeEnvironmentCatalogSettled: true, + runtimeStatusByEnvironmentId: new Map(), + automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, + setAutomationHostFilter: noop, + settings: getDefaultSettings('/tmp'), + preflightStatus: null, + preflightStatusChecked: true, + preflightStatusContextKey: null, + refreshPreflightStatus: noop, + selectedAutomationId: null, + setSelectedAutomationId: noop, + pendingAutomationRunNavigation: null, + setPendingAutomationRunNavigation: noop, + hydratePersistedUI: noop, + recordFeatureInteraction: noop, + allWorktrees: () => [worktree], + getKnownWorktreeById: () => worktree + }, + repoMap: new Map([[REPO_ID, repo]]), + worktreeMap: new Map([[WORKSPACE_ID, worktree]]) + } +} diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx new file mode 100644 index 00000000000..f8babde3716 --- /dev/null +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -0,0 +1,433 @@ +/** + * The mount rig for AutomationsPage tests: child stand-ins, the preload API + * double, and the per-test store reset. + * + * The presentational children are stood in for because what these tests check + * is the data flow into them, not their markup — asserting on the real picker, + * badges, and empty states would test the wrong layer. The stand-ins record the + * props they were handed so a test can read them back. + * + * The `vi.mock` calls live here rather than in each test file: they must run + * before `./AutomationsPage` is imported, which importing this module first + * guarantees. + */ + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, type Mock, vi } from 'vitest' +import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { AppState } from '@/store' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import type { AutomationCreateDestinationControl } from './use-automation-create-destination' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import type { AutomationListRow } from './automation-list-row-identity' + +export type ListPanelProps = { + filteredAutomations: Automation[] + filteredExternalAutomationEntries: ExternalAutomationListEntry[] + selectedExternal: ExternalAutomationListEntry | null + openEditExternalDialog: ( + manager: ExternalAutomationListEntry['manager'], + job: ExternalAutomationListEntry['job'], + scope: ExternalAutomationListEntry['scope'] + ) => void + externalActionKey: string | null + requestExternalAction: ( + manager: ExternalAutomationListEntry['manager'], + job: ExternalAutomationListEntry['job'], + action: 'run' | 'pause' | 'resume' | 'delete', + scope: ExternalAutomationListEntry['scope'] + ) => void + hasListItems: boolean + hasFilteredListItems: boolean + filteredRows: readonly AutomationListRow[] + selectedRowKey: string | null + selectedExternalKey: string | null + hostCatalog: AutomationHostCatalogView + searchCounts: { hostRowCount: number; visibleRowCount: number; searchActive: boolean } + externalManagersListed: boolean + externalScopeNotice: string | null + externalManagersUncheckedNotice: string | null + isActionEnabled: (row: AutomationListRow, action: string) => boolean + onSelectHost: (filter: unknown) => void + selectAutomationRow: (rowKey: string | null) => void + selectExternalKey: (entryKey: string | null) => void + onOpenDetail: () => void + onRefresh: () => void + runNow: (row: AutomationListRow) => void + openEditDialog: (row: AutomationListRow) => void + toggleAutomation: (row: AutomationListRow) => void + requestDeleteAutomation: (row: AutomationListRow) => void + openCreateDialog: () => void +} + +export type DetailPaneProps = { + selected: Automation | null + selectedRuns: AutomationRun[] + selectedRunsNotice: { message: string } | null + runNow: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + fetchExternalAutomationRuns: (input: { + scope: ExternalAutomationListEntry['scope'] + manager: ExternalAutomationListEntry['manager'] + job: ExternalAutomationListEntry['job'] + page: number + pageSize: number + }) => Promise +} + +export type EditorDialogProps = { + open: boolean + isEditing: boolean + createDestination?: AutomationCreateDestinationControl + notice?: { message: string } | null + onSave: () => void + onDraftChange: (updater: (current: unknown) => unknown) => void +} + +export type DeleteDialogProps = { + deleteTarget: Automation | null + onConfirm: () => void +} + +type AutomationsPageMocks = { + state: Record + repoMap: Map + worktreeMap: Map + listPanel: ListPanelProps | null + detailPane: DetailPaneProps | null + editorDialog: EditorDialogProps | null + deleteDialog: DeleteDialogProps | null + callRuntimeRpc: Mock + getRuntimeEnvironmentStatus: Mock + setAutomationHostFilter: Mock + toastError: Mock + toastSuccess: Mock + toastMessage: Mock + setPendingRunNavigation: Mock +} + +// Not `vi.hoisted`: the mock factories below only close over this, and a hoisted +// binding cannot be exported. +export const mocks: AutomationsPageMocks = { + state: {}, + repoMap: new Map(), + worktreeMap: new Map(), + listPanel: null, + detailPane: null, + editorDialog: null, + deleteDialog: null, + callRuntimeRpc: vi.fn(), + getRuntimeEnvironmentStatus: vi.fn(), + setAutomationHostFilter: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastMessage: vi.fn(), + setPendingRunNavigation: vi.fn() +} + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: Partial) => unknown): unknown => + selector(mocks.state as Partial) + useAppStore.getState = (): Partial => mocks.state as Partial + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useRepoMap: () => mocks.repoMap, + useWorktreeMap: () => mocks.worktreeMap +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => mocks.getRuntimeEnvironmentStatus(...args), + // Matches the real matcher's contract: the code is a `: ` message tail. + hasRuntimeRpcErrorCode: (error: unknown, code: string) => + error instanceof Error && error.message.trimEnd().endsWith(`: ${code}`) +})) + +vi.mock('sonner', () => ({ + toast: { + error: (...args: unknown[]) => mocks.toastError(...args), + success: (...args: unknown[]) => mocks.toastSuccess(...args), + message: (...args: unknown[]) => mocks.toastMessage(...args) + } +})) + +vi.mock('@/lib/window-visibility-interval', () => ({ + installWindowVisibilityInterval: () => () => undefined +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: () => undefined +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null +})) + +vi.mock('@/runtime/runtime-hooks-client', () => ({ + checkRuntimeHooks: vi.fn(async () => ({ hooks: null, ok: true })) +})) + +vi.mock('@/lib/ensure-hooks-confirmed', () => ({ + ensureHooksConfirmed: vi.fn(async () => 'inherit') +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('./AutomationsListPanel', () => ({ + AutomationsListPanel: (props: ListPanelProps) => { + const selectAutomationRow = (rowKey: string | null): void => { + props.selectAutomationRow(rowKey) + props.onOpenDetail() + } + mocks.listPanel = { ...props, selectAutomationRow } + return ( +
+ + ))} + {props.filteredExternalAutomationEntries.map((entry) => ( + + ))} + {props.hasListItems ? null :
} +
+ ) + } +})) + +vi.mock('./AutomationsDetailPane', () => ({ + AutomationsDetailPane: (props: DetailPaneProps) => { + mocks.detailPane = props + return ( +
+ {props.selected?.name ?? 'none'} + {props.selectedRuns.length} +
+ ) + } +})) + +vi.mock('./AutomationEditorDialog', () => ({ + AutomationEditorDialog: (props: EditorDialogProps) => { + mocks.editorDialog = props + return
{props.open ? 'open' : 'closed'}
+ } +})) + +vi.mock('./AutomationDeleteDialogs', () => ({ + AutomationDeleteDialog: (props: DeleteDialogProps) => { + mocks.deleteDialog = props + return null + }, + ExternalAutomationDeleteDialog: () => null +})) + +import AutomationsPage from './AutomationsPage' +import { makeAutomation, makeRun, makeStoreState } from './automations-page-fixtures' + +function mockGroup(...names: K[]): Record { + return Object.fromEntries(names.map((name) => [name, vi.fn()])) as Record +} + +export const api = { + automations: mockGroup( + 'list', + 'listRuns', + 'create', + 'update', + 'delete', + 'runNow', + 'listScoped', + 'listExternalManagerForOwner', + 'listExternalRunsForOwner', + 'createExternalForOwner', + 'updateExternalForOwner', + 'runExternalActionForOwner', + 'retainExternalScopes' + ), + ssh: mockGroup('listTargets', 'connect'), + runtimeEnvironments: mockGroup('connect'), + ui: mockGroup('get') +} + +/** The precondition a Desktop + Self row captures; every fenced request repeats it. */ +export const SELF_PRECONDITION = { selector: { kind: 'self' } } +/** The owner the catalog projects for Desktop + Self, which every scoped call names. */ +export const DESKTOP_SELF_OWNER = { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + +export const RUNTIME_ID = 'gpu' +/** A runtime that advertises both automation capabilities, so its rows carry owners. */ +const RUNTIME_CAPABILITIES = [ + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY +] +export const RUNTIME_SELF_FILTER = { + kind: 'host' as const, + host: { + authority: { kind: 'runtime' as const, environmentId: RUNTIME_ID }, + selector: { kind: 'self' as const } + } +} + +/** Puts a reachable, capable second authority in the catalog and answers for it. */ +export function runtimeHost(automations: Automation[], runs: AutomationRun[]): void { + mocks.state.runtimeEnvironments = [ + { id: RUNTIME_ID, name: 'GPU box', createdAt: 1, pairingRevision: 4 } + ] + mocks.state.runtimeStatusByEnvironmentId = new Map([ + [RUNTIME_ID, { status: { capabilities: RUNTIME_CAPABILITIES } }] + ]) + mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: RUNTIME_CAPABILITIES }) + mocks.callRuntimeRpc.mockImplementation(async (_target: unknown, method: string) => { + if (method === 'automation.list') { + return { + automations, + items: automations.map((automation) => ({ + automationId: automation.id, + selector: { kind: 'self' } + })), + orphanCount: 0 + } + } + return method === 'automation.runs' ? { runs } : {} + }) +} + +/** Answers the host-scoped read, which is where the list's rows and owners come from. */ +export function scopedList(automations: Automation[]): void { + api.automations.listScoped.mockResolvedValue({ + automations, + items: automations.map((automation) => ({ + automationId: automation.id, + selector: { kind: 'self' } + })), + orphanCount: 0 + }) +} + +const roots: Root[] = [] + +export async function renderPage(): Promise<{ + container: HTMLDivElement + rerender: () => Promise +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + const rerender = async (): Promise => { + await act(async () => { + root.render() + }) + } + await rerender() + return { container, rerender } +} + +/** Lets the per-host queries, their capability probes, and the history read settle. */ +export async function settleHostQueries(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** The alt-tab-back trigger: one of the two events bound to the page refresh. */ +export async function refreshOnFocus(): Promise { + await act(async () => { + window.dispatchEvent(new Event('focus')) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** + * The row the page actually listed for an ID, so tests act through the same + * authority-qualified key the user's click carries rather than a synthesized one. + */ +export function listedRow(automationId: string): AutomationListRow { + const row = mocks.listPanel?.filteredRows.find((entry) => entry.automation.id === automationId) + if (!row) { + throw new Error(`no listed row for ${automationId}`) + } + return row +} + +export function rows(container: HTMLElement, testId: string): string[] { + return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map( + (node) => node.textContent ?? '' + ) +} + +/** Registers the reset both page test files depend on; call once per file. */ +export function installAutomationsPageHarness(): void { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + mocks.listPanel = null + mocks.detailPane = null + mocks.editorDialog = null + mocks.deleteDialog = null + const fixtures = makeStoreState() + mocks.state = fixtures.state + mocks.state.setAutomationHostFilter = mocks.setAutomationHostFilter + mocks.repoMap = fixtures.repoMap + mocks.worktreeMap = fixtures.worktreeMap + api.automations.list.mockResolvedValue([makeAutomation()]) + api.automations.listRuns.mockResolvedValue([]) + api.automations.listExternalManagerForOwner.mockResolvedValue({ + manager: null, + error: null, + updatedAt: 0 + }) + api.automations.retainExternalScopes.mockResolvedValue(undefined) + api.automations.listScoped.mockResolvedValue({ + automations: [makeAutomation()], + items: [{ automationId: 'a-1', selector: { kind: 'self' } }], + orphanCount: 0 + }) + api.automations.create.mockResolvedValue(makeAutomation()) + api.automations.update.mockResolvedValue(makeAutomation()) + api.automations.delete.mockResolvedValue(undefined) + api.automations.runNow.mockResolvedValue(makeRun()) + api.ui.get.mockResolvedValue({}) + // @ts-expect-error test window mock + globalThis.window.api = api + }) + + afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' + }) +} diff --git a/src/renderer/src/components/automations/external-automation-display.test.ts b/src/renderer/src/components/automations/external-automation-display.test.ts new file mode 100644 index 00000000000..7614352e377 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-display.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { isMissingExternalRunsApiError } from './external-automation-display' +import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../../../shared/external-automation-scope' + +describe('isMissingExternalRunsApiError', () => { + it('recognizes the relay code carried in the message', () => { + // Electron IPC keeps only the message, so the code rides inside it. + expect( + isMissingExternalRunsApiError( + new Error( + `Error invoking remote method: This host does not report external automation run history.: ${EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported}` + ) + ) + ).toBe(true) + }) + + it('still recognizes hosts that answer with no code at all', () => { + expect( + isMissingExternalRunsApiError(new Error("No handler registered for 'automations:listRuns'")) + ).toBe(true) + expect(isMissingExternalRunsApiError(new Error('listExternalRuns is not a function'))).toBe( + true + ) + }) + + it('does not treat an ordinary relay failure as a missing endpoint', () => { + // Falling back here would render a truncated run list as if it were complete. + expect(isMissingExternalRunsApiError(new Error('SSH target "ssh-1" is not connected.'))).toBe( + false + ) + expect(isMissingExternalRunsApiError(new Error('socket hang up'))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts index beb7fc20082..388598269ea 100644 --- a/src/renderer/src/components/automations/external-automation-display.ts +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -1,19 +1,12 @@ import React from 'react' import type { Badge } from '@/components/ui/badge' import type { - ExternalAutomationJob, ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' +import { EXTERNAL_AUTOMATION_SCOPE_CODES } from '../../../../shared/external-automation-scope' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' -export function getExternalAutomationKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob -): string { - return `${manager.id}:${job.id}` -} - export function formatExternalDate(value: string | null, now: number): string { if (!value) { return 'Never' @@ -63,5 +56,11 @@ export function getExternalRunContent(run: ExternalAutomationRun): string { export function isMissingExternalRunsApiError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) - return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) + // The relay path now reports a code, which is exact. The patterns stay for the + // hosts that answer with no code at all: an unrouted IPC channel, and relays + // predating the code. Neither can be tightened without losing those hosts. + return ( + message.includes(EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported) || + /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) + ) } diff --git a/src/renderer/src/components/automations/external-automation-list-entries.test.ts b/src/renderer/src/components/automations/external-automation-list-entries.test.ts index 8db60dd1c05..d2609ebb834 100644 --- a/src/renderer/src/components/automations/external-automation-list-entries.test.ts +++ b/src/renderer/src/components/automations/external-automation-list-entries.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ExternalAutomationManager } from '../../../../shared/automations-types' import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import type { ScopedExternalAutomationManager } from './external-automation-scope-client' function manager(overrides: Partial = {}): ExternalAutomationManager { return { @@ -17,42 +18,82 @@ function manager(overrides: Partial = {}): ExternalAu } } +function job(id = 'job-1'): ExternalAutomationManager['jobs'][number] { + return { + id, + managerId: 'hermes:ssh:openclaw', + provider: 'hermes', + name: 'Nightly', + schedule: '0 9 * * *', + rawSchedule: '0 9 * * *', + enabled: true, + state: 'active', + prompt: 'do work', + promptPreview: 'do work', + nextRunAt: null, + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 1, + runs: [] + } +} + +function scoped( + environmentId: string | null, + overrides: Partial = {} +): ScopedExternalAutomationManager { + return { + scope: { + owner: { + authority: + environmentId === null + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId, pairingRevision: 1 }, + selector: { kind: 'self' } + }, + provider: 'hermes' + }, + manager: manager(overrides) + } +} + describe('buildExternalAutomationListEntries', () => { it('omits empty host probes', () => { - expect(buildExternalAutomationListEntries([manager()])).toEqual([]) + expect(buildExternalAutomationListEntries([scoped(null)])).toEqual([]) }) it('lists jobs from an unavailable manager', () => { - expect( - buildExternalAutomationListEntries([ - manager({ - jobs: [ - { - id: 'job-1', - managerId: 'hermes:ssh:openclaw', - provider: 'hermes', - name: 'Nightly', - schedule: '0 9 * * *', - rawSchedule: '0 9 * * *', - enabled: true, - state: 'active', - prompt: 'do work', - promptPreview: 'do work', - nextRunAt: null, - lastRunAt: null, - lastStatus: null, - lastError: null, - workdir: null, - runCount: 1, - runs: [] - } - ] - }) - ]) - ).toEqual([ + expect(buildExternalAutomationListEntries([scoped(null, { jobs: [job()] })])).toEqual([ expect.objectContaining({ job: expect.objectContaining({ id: 'job-1' }) }) ]) }) + + it('separates identical manager and job IDs reported by two authorities', () => { + // `hermes:local` names no authority, so both hosts hand back the same manager + // ID. An unqualified key would make the two rows one, and the second would + // shadow the first in selection, dialogs, and React's reconciliation. + const entries = buildExternalAutomationListEntries([ + scoped(null, { id: 'hermes:local', target: { type: 'local' }, jobs: [job()] }), + scoped('env-7', { id: 'hermes:local', target: { type: 'local' }, jobs: [job()] }) + ]) + + expect(entries).toHaveLength(2) + expect(entries[0]?.key).not.toBe(entries[1]?.key) + }) + + it('carries the scope it keyed each entry with', () => { + const [entry] = buildExternalAutomationListEntries([scoped('env-7', { jobs: [job()] })]) + + // The entry is what the edit dialog captures; re-deriving the scope from the + // manager ID at save time is the lookup this field exists to avoid. + expect(entry?.scope.owner.authority).toEqual({ + kind: 'runtime', + environmentId: 'env-7', + pairingRevision: 1 + }) + }) }) diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts index 5e89684132a..cd27acea580 100644 --- a/src/renderer/src/components/automations/external-automation-list-entries.ts +++ b/src/renderer/src/components/automations/external-automation-list-entries.ts @@ -2,21 +2,29 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' -import { getExternalAutomationKey } from './external-automation-display' +import type { + ExternalAutomationScope, + ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { externalAutomationJobKey } from './external-automation-scope-keys' export type ExternalAutomationListEntry = { key: string + /** The scope the manager was listed under; carried so the key never has to be re-derived. */ + scope: ExternalAutomationScope manager: ExternalAutomationManager job: ExternalAutomationJob } export function buildExternalAutomationListEntries( - managers: readonly ExternalAutomationManager[] + scopedManagers: readonly ScopedExternalAutomationManager[] ): ExternalAutomationListEntry[] { // Why: empty managers are host probes, not automations — omit them from the list. - return managers.flatMap((manager) => + return scopedManagers.flatMap(({ scope, manager }) => manager.jobs.map((job) => ({ - key: getExternalAutomationKey(manager, job), + // A scope holds at most one manager, so {scope, job} names the row exactly. + key: externalAutomationJobKey(scope, job.id), + scope, manager, job })) diff --git a/src/renderer/src/components/automations/external-automation-scope-client.ts b/src/renderer/src/components/automations/external-automation-scope-client.ts new file mode 100644 index 00000000000..7b50d45636c --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-client.ts @@ -0,0 +1,181 @@ +/** + * The renderer's scoped external-manager surface. + * + * Every call names the owner the host was captured under, so a manager ID — + * which is only unique inside one host — can never route a mutation to a + * different machine. The owner is recorded when a manager is discovered and + * replayed on every later action, rather than re-derived from the manager's own + * target at action time. + */ + +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationProvider, + ExternalAutomationRun +} from '../../../../shared/automations-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { + EXTERNAL_AUTOMATION_PROVIDERS, + type ScopedExternalManagerMutationFields +} from '../../../../shared/external-automation-scope' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { resolveExternalAutomationScopeGate } from './external-automation-scope-gating' + +export type ExternalAutomationScope = { + owner: AutomationOwnerRef + provider: ExternalAutomationProvider +} + +/** + * A discovered manager still carrying the scope it was listed under. + * + * Why paired rather than looked up: a manager ID is `hermes:local` on every host + * that has no SSH target, so it does not name an authority. Recovering the scope + * from the ID would collide for exactly the hosts a scope is meant to separate. + */ +export type ScopedExternalAutomationManager = { + scope: ExternalAutomationScope + manager: ExternalAutomationManager +} + +/** A scope that answered with neither a manager nor a denial: it was not checked. */ +export type ScopedExternalAutomationFailure = { + scope: ExternalAutomationScope + message: string +} + +export type ScopedExternalAutomationManagers = { + managers: readonly ScopedExternalAutomationManager[] + /** Carries the owner, not just a key, so a failed host can be named to the user. */ + failures: readonly ScopedExternalAutomationFailure[] +} + +const EMPTY_MANAGERS: ScopedExternalAutomationManagers = { + managers: [], + failures: [] +} + +/** Every `{host, provider}` pair the gate says may actually be probed. */ +export function externalAutomationScopes( + entries: readonly AutomationHostCatalogEntry[] +): ExternalAutomationScope[] { + const scopes: ExternalAutomationScope[] = [] + for (const entry of entries) { + const owner = resolveExternalAutomationScopeGate(entry).probeOwner + if (!owner) { + continue + } + for (const provider of EXTERNAL_AUTOMATION_PROVIDERS) { + scopes.push({ owner, provider }) + } + } + return scopes +} + +/** Takes scopes, not the catalog: the caller decides which hosts are in view. */ +export async function listScopedExternalAutomationManagers( + scopes: readonly ExternalAutomationScope[] +): Promise { + if (scopes.length === 0) { + return EMPTY_MANAGERS + } + const managers: ScopedExternalAutomationManager[] = [] + const failures: ScopedExternalAutomationFailure[] = [] + const results = await Promise.all( + scopes.map(async (scope) => { + try { + return { scope, result: await window.api.automations.listExternalManagerForOwner(scope) } + } catch (error) { + // One provider's failure is a fact about that provider, never about the host. + return { + scope, + result: { + manager: null, + error: error instanceof Error ? error.message : String(error), + updatedAt: 0 + } + } + } + }) + ) + for (const { scope, result } of results) { + if (result.error) { + failures.push({ scope, message: result.error }) + } + if (!result.manager) { + continue + } + managers.push({ scope, manager: result.manager }) + } + return { managers, failures } +} + +/** + * The desktop host that owns a repo's external managers. Null when no owner + * could be captured for it, which disables creation rather than defaulting one. + */ +export function desktopExternalAutomationOwner( + entries: readonly AutomationHostCatalogEntry[], + connectionId: string | null +): AutomationOwnerRef | null { + for (const entry of entries) { + if (entry.stableRef.authority.kind !== 'desktop' || !entry.owner) { + continue + } + const selector = entry.stableRef.selector + const matches = + connectionId === null + ? selector.kind === 'self' + : selector.kind === 'ssh' && selector.targetId === connectionId + if (matches) { + return entry.owner + } + } + return null +} + +export async function createScopedExternalAutomation( + scope: ExternalAutomationScope, + fields: ScopedExternalManagerMutationFields +): Promise { + await window.api.automations.createExternalForOwner({ ...scope, ...fields }) +} + +export async function updateScopedExternalAutomation( + scope: ExternalAutomationScope, + jobId: string, + fields: ScopedExternalManagerMutationFields +): Promise { + await window.api.automations.updateExternalForOwner({ ...scope, ...fields, jobId }) +} + +export async function runScopedExternalAutomationAction( + scope: ExternalAutomationScope, + jobId: string, + action: ExternalAutomationAction +): Promise { + await window.api.automations.runExternalActionForOwner({ ...scope, jobId, action }) +} + +export type ScopedExternalAutomationRunsPage = { + runs: readonly ExternalAutomationRun[] + totalCount: number +} + +export async function listScopedExternalAutomationRuns( + scope: ExternalAutomationScope, + job: ExternalAutomationJob, + page: number, + pageSize: number +): Promise { + const result = await window.api.automations.listExternalRunsForOwner({ + ...scope, + jobId: job.id, + // The engine pages from one; the table's page index is zero-based. + page: page + 1, + pageSize + }) + return { runs: result.runs, totalCount: result.total } +} diff --git a/src/renderer/src/components/automations/external-automation-scope-gating.test.ts b/src/renderer/src/components/automations/external-automation-scope-gating.test.ts new file mode 100644 index 00000000000..73e2d770b4a --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-gating.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + ExternalAutomationScopeError +} from '../../../../shared/external-automation-scope' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + externalAutomationProbeOwners, + externalAutomationScopeGateFromError, + externalAutomationScopeNotice, + externalManagersListedForEntries, + externalManagersListedForEntry, + parseExternalAutomationScopeCode, + resolveExternalAutomationScopeGate +} from './external-automation-scope-gating' + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +const runtimeEntry = entry({ + stableRef: { authority: { kind: 'runtime', environmentId: 'env-1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:env-1:self', + label: 'Build box', + authorityLabel: 'Build box' +}) + +describe('external automation scope gating', () => { + it('lists managers only for a desktop host with a captured owner', () => { + const gate = resolveExternalAutomationScopeGate(entry()) + expect(gate.status).toBe('listed') + expect(gate.probeOwner).toEqual({ + authority: { kind: 'desktop' }, + selector: { kind: 'self' } + }) + expect(externalAutomationScopeNotice(gate)).toBeNull() + }) + + it('never presents a runtime-owned host as clean', () => { + const gate = resolveExternalAutomationScopeGate(runtimeEntry) + expect(gate.status).toBe('not-listed') + expect(gate.code).toBe(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + expect(gate.probeOwner).toBeNull() + expect(externalManagersListedForEntry(runtimeEntry)).toBe(false) + expect(externalAutomationScopeNotice(gate)).toBe( + 'External automation managers are not listed for this host in this release.' + ) + }) + + it('fails closed when no owner was captured, so nothing is probed', () => { + const gate = resolveExternalAutomationScopeGate(entry({ owner: null })) + expect(gate.status).toBe('not-listed') + expect(gate.probeOwner).toBeNull() + expect(externalAutomationScopeNotice(gate)).not.toBeNull() + }) + + it('treats the orphan bucket as scope limited rather than empty', () => { + const orphan = entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, + kind: 'orphan', + owner: null, + stableKey: 'host:desktop:orphan' + }) + expect(resolveExternalAutomationScopeGate(orphan).status).toBe('not-listed') + expect(externalManagersListedForEntry(orphan)).toBe(false) + }) + + it('makes no claim for an unresolved host', () => { + const gate = resolveExternalAutomationScopeGate(null) + expect(gate.status).toBe('unknown') + expect(gate.probeOwner).toBeNull() + // Why: 'unknown' is not a limitation, so it must not print the scope note. + expect(externalAutomationScopeNotice(gate)).toBeNull() + expect(externalManagersListedForEntry(null)).toBe(false) + }) + + it('keeps the note on screen when any host in the view is scope limited', () => { + expect(externalManagersListedForEntries([entry()])).toBe(true) + expect(externalManagersListedForEntries([entry(), runtimeEntry])).toBe(false) + // No hosts is not evidence that everything was listed. + expect(externalManagersListedForEntries([])).toBe(false) + }) + + it('retains probe owners only for hosts that are actually listed', () => { + expect(externalAutomationProbeOwners([entry(), runtimeEntry, entry({ owner: null })])).toEqual([ + { authority: { kind: 'desktop' }, selector: { kind: 'self' } } + ]) + }) +}) + +describe('engine scope errors', () => { + it('reads the code the engine appends to its message', () => { + const hidden = new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + expect(parseExternalAutomationScopeCode(hidden)).toBe( + EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden + ) + // Electron rewraps the message; the trailing token still terminates it. + expect( + parseExternalAutomationScopeCode( + new Error(`Error invoking remote method 'x': ${hidden.message}`) + ) + ).toBe(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + }) + + it('turns a hidden-target rejection into its own copy, not the generic note', () => { + const gate = externalAutomationScopeGateFromError( + new ExternalAutomationScopeError(EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) + ) + expect(gate?.status).toBe('not-listed') + expect(externalAutomationScopeNotice(gate!)).toBe( + 'This host is managed by Orca, so its external automation managers are not listed in this release.' + ) + }) + + it('leaves unrelated failures alone so they are not relabelled as scope limits', () => { + expect(externalAutomationScopeGateFromError(new Error('socket hang up'))).toBeNull() + expect(parseExternalAutomationScopeCode(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-scope-gating.ts b/src/renderer/src/components/automations/external-automation-scope-gating.ts new file mode 100644 index 00000000000..034e19c5819 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-gating.ts @@ -0,0 +1,144 @@ +/** + * Whether a host's external automation managers are actually listed. + * + * External managers are a desktop-authority surface this release, so most hosts + * are *scope limited* rather than empty. The distinction is load-bearing: a + * runtime-owned host with no rows has not been probed, and presenting it as + * clean would tell the user something we did not check. + */ + +import { + EXTERNAL_AUTOMATION_SCOPE_CODES, + type ExternalAutomationScopeCode +} from '../../../../shared/external-automation-scope' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { translate } from '@/i18n/i18n' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' + +export type ExternalAutomationScopeStatus = 'listed' | 'not-listed' | 'unknown' + +export type ExternalAutomationScopeGate = { + status: ExternalAutomationScopeStatus + /** Non-null only when `listed`; every other status must not probe. */ + probeOwner: AutomationOwnerRef | null + /** Engine code when one explains the limit; null when no owner was captured. */ + code: ExternalAutomationScopeCode | null +} + +const UNKNOWN_GATE: ExternalAutomationScopeGate = { + status: 'unknown', + probeOwner: null, + code: null +} + +function notListed(code: ExternalAutomationScopeCode | null): ExternalAutomationScopeGate { + return { status: 'not-listed', probeOwner: null, code } +} + +/** + * Resolves one host. A null entry is the unresolved/All-hosts case and makes no + * claim either way; compose per-host gates for All hosts instead. + */ +export function resolveExternalAutomationScopeGate( + entry: AutomationHostCatalogEntry | null +): ExternalAutomationScopeGate { + if (!entry) { + return UNKNOWN_GATE + } + if (entry.stableRef.authority.kind !== 'desktop') { + return notListed(EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported) + } + if (entry.kind === 'orphan') { + return notListed(null) + } + // Fail closed: an uncaptured owner cannot be probed, so nothing is listed for it. + return entry.owner ? { status: 'listed', probeOwner: entry.owner, code: null } : notListed(null) +} + +/** The value `AutomationListEmptyView` requires; never derived from row counts. */ +export function externalManagersListedForEntry(entry: AutomationHostCatalogEntry | null): boolean { + return resolveExternalAutomationScopeGate(entry).status === 'listed' +} + +/** + * All-hosts variant: one scope-limited host in the view is enough to keep the + * note on screen, because the combined list is then incomplete. + */ +export function externalManagersListedForEntries( + entries: readonly AutomationHostCatalogEntry[] +): boolean { + return entries.length > 0 && entries.every((entry) => externalManagersListedForEntry(entry)) +} + +/** + * The hosts whose external managers are in view. Probing and retention both key + * off this: a host the user filtered away is not a host we may go ask about. + */ +export function externalAutomationScopeEntries( + entries: readonly AutomationHostCatalogEntry[], + resolution: AutomationHostFilterResolution +): readonly AutomationHostCatalogEntry[] { + if (resolution.effective.kind === 'all') { + return entries + } + return resolution.entry ? [resolution.entry] : [] +} + +/** Owners the probe pool may keep working on; everything else is cancelled. */ +export function externalAutomationProbeOwners( + entries: readonly AutomationHostCatalogEntry[] +): AutomationOwnerRef[] { + const owners: AutomationOwnerRef[] = [] + for (const entry of entries) { + const gate = resolveExternalAutomationScopeGate(entry) + if (gate.probeOwner) { + owners.push(gate.probeOwner) + } + } + return owners +} + +/** + * The scope-limitation line. Shown in the populated state too — the empty state + * is not the only place a host can be silently incomplete. + */ +export function externalAutomationScopeNotice(gate: ExternalAutomationScopeGate): string | null { + if (gate.status !== 'not-listed') { + return null + } + if (gate.code === EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden) { + return translate( + 'auto.components.automations.externalScope.targetHidden', + 'This host is managed by Orca, so its external automation managers are not listed in this release.' + ) + } + // Same string as the empty state's scope note, so the two never drift apart. + return translate( + 'auto.components.automations.emptyState.externalManagersOutOfScope', + 'External automation managers are not listed for this host in this release.' + ) +} + +/** + * Maps a rejected scoped call onto a gate. The engine carries its code as a + * trailing `: ` token because IPC preserves nothing but the message. + */ +export function externalAutomationScopeGateFromError( + error: unknown +): ExternalAutomationScopeGate | null { + const code = parseExternalAutomationScopeCode(error) + return code ? notListed(code) : null +} + +export function parseExternalAutomationScopeCode( + error: unknown +): ExternalAutomationScopeCode | null { + const message = (error instanceof Error ? error.message : String(error ?? '')).trimEnd() + for (const code of Object.values(EXTERNAL_AUTOMATION_SCOPE_CODES)) { + if (message.endsWith(`: ${code}`)) { + return code + } + } + return null +} diff --git a/src/renderer/src/components/automations/external-automation-scope-keys.test.ts b/src/renderer/src/components/automations/external-automation-scope-keys.test.ts new file mode 100644 index 00000000000..7209af24a1a --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-keys.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { + externalAutomationActionKey, + externalAutomationDialogKey, + externalAutomationJobKey, + externalAutomationManagerKey, + externalAutomationRunKey, + externalAutomationScopeKey, + type ExternalAutomationScopeRef +} from './external-automation-scope-keys' + +const localOwner: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} +const sshOwner = (targetId = 't1', targetGeneration = 3): AutomationOwnerRef => ({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } +}) + +const local: ExternalAutomationScopeRef = { owner: localOwner, provider: 'hermes' } +const remote: ExternalAutomationScopeRef = { owner: sshOwner(), provider: 'hermes' } + +describe('external automation scope keys', () => { + it('separates the same provider ID across hosts', () => { + expect(externalAutomationJobKey(local, 'job-1')).not.toBe( + externalAutomationJobKey(remote, 'job-1') + ) + expect(externalAutomationManagerKey(local, 'hermes:local')).not.toBe( + externalAutomationManagerKey(remote, 'hermes:local') + ) + }) + + it('separates providers on one host', () => { + expect(externalAutomationJobKey(local, 'job-1')).not.toBe( + externalAutomationJobKey({ ...local, provider: 'openclaw' }, 'job-1') + ) + }) + + it('separates host incarnations, so a re-added target does not adopt old keys', () => { + expect(externalAutomationScopeKey(remote)).not.toBe( + externalAutomationScopeKey({ owner: sshOwner('t1', 4), provider: 'hermes' }) + ) + }) + + it('keeps key kinds in separate namespaces', () => { + const kinds = new Set([ + externalAutomationScopeKey(local), + externalAutomationManagerKey(local, 'x'), + externalAutomationJobKey(local, 'x'), + externalAutomationRunKey(local, 'x', 'x'), + externalAutomationActionKey(local, 'x', 'run'), + externalAutomationDialogKey(local, 'x') + ]) + expect(kinds.size).toBe(6) + }) + + it('distinguishes a create dialog from a job literally named new', () => { + expect(externalAutomationDialogKey(local, null)).not.toBe( + externalAutomationDialogKey(local, 'new') + ) + }) + + it('escapes components so a hostile ID cannot forge another key', () => { + const forged = externalAutomationJobKey(local, 'job|extra') + expect(forged).not.toBe(externalAutomationRunKey(local, 'job', 'extra')) + expect(forged.split('|')).toHaveLength(4) + }) + + it('distinguishes actions on the same job', () => { + expect(externalAutomationActionKey(local, 'job-1', 'run')).not.toBe( + externalAutomationActionKey(local, 'job-1', 'delete') + ) + }) +}) diff --git a/src/renderer/src/components/automations/external-automation-scope-keys.ts b/src/renderer/src/components/automations/external-automation-scope-keys.ts new file mode 100644 index 00000000000..64362e803ef --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-scope-keys.ts @@ -0,0 +1,70 @@ +/** + * Keys for external manager rows, jobs, runs, dialogs, and in-flight actions. + * + * A provider's manager ID is unique only inside one host, so a key built from it + * alone collides the moment two hosts run the same provider — the selected row, + * the open dialog, and the disabled action button would all follow the wrong + * host. Every key is therefore prefixed with its kind, the captured owner, and + * the provider. + */ + +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { + ExternalAutomationAction, + ExternalAutomationProvider +} from '../../../../shared/automations-types' + +const SEPARATOR = '|' + +export type ExternalAutomationScopeRef = { + owner: AutomationOwnerRef + provider: ExternalAutomationProvider +} + +// Why: components are escaped so a provider-supplied ID cannot forge another key. +function key(kind: string, scope: ExternalAutomationScopeRef, ...rest: string[]): string { + return [kind, ownerKey(scope.owner), scope.provider, ...rest] + .map((part) => encodeURIComponent(part)) + .join(SEPARATOR) +} + +/** Cache, retention, and in-flight identity for one `{owner, provider}`. */ +export function externalAutomationScopeKey(scope: ExternalAutomationScopeRef): string { + return key('scope', scope) +} + +export function externalAutomationManagerKey( + scope: ExternalAutomationScopeRef, + managerId: string +): string { + return key('manager', scope, managerId) +} + +export function externalAutomationJobKey(scope: ExternalAutomationScopeRef, jobId: string): string { + return key('job', scope, jobId) +} + +export function externalAutomationRunKey( + scope: ExternalAutomationScopeRef, + jobId: string, + runId: string +): string { + return key('run', scope, jobId, runId) +} + +export function externalAutomationActionKey( + scope: ExternalAutomationScopeRef, + jobId: string, + action: ExternalAutomationAction +): string { + return key('action', scope, jobId, action) +} + +/** A create dialog has no job yet, and 'new' is a segment of its own so a job literally named "new" cannot alias it. */ +export function externalAutomationDialogKey( + scope: ExternalAutomationScopeRef, + jobId: string | null +): string { + return jobId === null ? key('dialog', scope, 'new') : key('dialog', scope, 'job', jobId) +} diff --git a/src/renderer/src/components/automations/external-automation-unchecked-hosts.ts b/src/renderer/src/components/automations/external-automation-unchecked-hosts.ts new file mode 100644 index 00000000000..2a2b94783ed --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-unchecked-hosts.ts @@ -0,0 +1,62 @@ +/** + * Hosts whose external managers a probe never answered for. + * + * A probe that failed is not a host with nothing on it. Without this line the + * list would render an unreachable host exactly like a clean one, which is the + * one claim the page has no evidence for. + */ + +import { translate } from '@/i18n/i18n' +import { ownerKey } from '../../../../shared/automation-owner-key' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import type { ScopedExternalAutomationFailure } from './external-automation-scope-client' + +export function externalAutomationUncheckedHostLabels( + failures: readonly ScopedExternalAutomationFailure[], + entries: readonly AutomationHostCatalogEntry[] +): string[] { + const labelByOwnerKey = new Map() + for (const entry of entries) { + if (entry.owner) { + labelByOwnerKey.set(ownerKey(entry.owner), entry.label) + } + } + const labels: string[] = [] + const seen = new Set() + // Per host, not per provider: two failed providers are still one unchecked host. + for (const failure of failures) { + const key = ownerKey(failure.scope.owner) + if (seen.has(key)) { + continue + } + seen.add(key) + labels.push( + labelByOwnerKey.get(key) ?? + translate('auto.components.automations.emptyState.unknownHost', 'this host') + ) + } + return labels +} + +/** Distinct from the empty state on purpose: unchecked is not the same as clean. */ +export function externalAutomationUncheckedNotice( + failures: readonly ScopedExternalAutomationFailure[], + entries: readonly AutomationHostCatalogEntry[] +): string | null { + const labels = externalAutomationUncheckedHostLabels(failures, entries) + if (labels.length === 0) { + return null + } + if (labels.length === 1) { + return translate( + 'auto.components.automations.externalScope.uncheckedHost', + 'External automation managers on {{hostLabel}} could not be checked.', + { hostLabel: labels[0] } + ) + } + return translate( + 'auto.components.automations.externalScope.uncheckedHosts', + 'External automation managers on {{count}} hosts could not be checked.', + { count: labels.length } + ) +} diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts new file mode 100644 index 00000000000..1b79000a6a3 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-create-destination.ts @@ -0,0 +1,176 @@ +/** + * The create dialog's destination: chosen once, shown, and re-checked at submit. + * + * The captured destination is deliberately state rather than a derivation of the + * live catalog. A dialog keeps the owner it captured for its whole lifetime, so + * a host that re-pairs or re-registers while the form is open is reported as + * moved instead of silently followed — which is the only way the user's "create + * it on that machine" survives the machine changing identity underneath it. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { translate } from '@/i18n/i18n' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import type { AutomationAuthorityRepoTables } from './automation-authority-identity' +import { + automationCreateProjectMismatch, + preselectAutomationCreateHost, + resolveAutomationCreateDestination, + revalidateAutomationCreateDestination, + soleAutomationCreateHost, + type AutomationCreateDestination, + type AutomationCreateDestinationChoiceReason, + type AutomationCreateDestinationResolution +} from './automation-create-destination' +import type { AutomationActionNotice } from './automation-row-action-dispatch' + +export type AutomationCreateDestinationControl = { + entries: readonly AutomationHostCatalogEntry[] + resolution: AutomationCreateDestinationResolution + onSelect: (stableKey: string) => void +} + +export type AutomationCreateDestinationCheck = + | { ok: true; destination: AutomationCreateDestination } + | { ok: false; notice: AutomationActionNotice } + +export type AutomationCreateDestinationInput = { + /** False whenever the dialog is closed or is not creating an Orca automation. */ + open: boolean + catalog: AutomationHostCatalog + entries: readonly AutomationHostCatalogEntry[] + /** Non-null only when the list is filtered to one concrete host. */ + filterStableKey: string | null + activeWorkspaceStableKey: string | null + repoTables: AutomationAuthorityRepoTables +} + +export type AutomationCreateDestinationState = { + control: AutomationCreateDestinationControl + check: (projectId: string) => AutomationCreateDestinationCheck +} + +function choiceMessage(reason: AutomationCreateDestinationChoiceReason): string { + if (reason === 'orphan') { + return translate( + 'auto.components.automations.createDestination.orphan', + 'Automations with no host cannot hold new ones. Choose a host to create this automation on.' + ) + } + if (reason === 'unavailable') { + return translate( + 'auto.components.automations.createDestination.unavailable', + 'That host cannot hold a new automation yet. Choose another host to create this one on.' + ) + } + return translate( + 'auto.components.automations.createDestination.unselected', + 'Choose the host this automation will be created on.' + ) +} + +function ownerNotice(message: string): AutomationActionNotice { + return { message, recovery: null, severity: 'owner' } +} + +export function useAutomationCreateDestination( + input: AutomationCreateDestinationInput +): AutomationCreateDestinationState { + const { open, catalog, entries, filterStableKey, activeWorkspaceStableKey, repoTables } = input + const [captured, setCaptured] = useState(null) + const [reason, setReason] = useState('unselected') + + useEffect(() => { + if (!open) { + setCaptured(null) + setReason('unselected') + return + } + if (captured) { + return + } + // Re-runs until it first resolves: a catalog still hydrating must not lock + // the form into "choose a host" once the host it would have picked arrives. + const entry = + preselectAutomationCreateHost(entries, filterStableKey, activeWorkspaceStableKey) ?? + soleAutomationCreateHost(entries, catalog.hydration) + const resolved = resolveAutomationCreateDestination(entry) + if (resolved.status === 'ready') { + setCaptured(resolved) + return + } + setReason(resolved.reason) + }, [activeWorkspaceStableKey, captured, catalog, entries, filterStableKey, open]) + + const onSelect = useCallback( + (stableKey: string) => { + const resolved = resolveAutomationCreateDestination( + entries.find((entry) => entry.stableKey === stableKey) + ) + setCaptured(resolved.status === 'ready' ? resolved : null) + if (resolved.status !== 'ready') { + setReason(resolved.reason) + } + }, + [entries] + ) + + const resolution = useMemo( + (): AutomationCreateDestinationResolution => + captured ? { status: 'ready', ...captured } : { status: 'choice-required', reason }, + [captured, reason] + ) + + // Why refs: the check runs inside an async submit, where a value closed over + // when the handler was created could be an incarnation old. + const entriesRef = useRef(entries) + entriesRef.current = entries + const repoTablesRef = useRef(repoTables) + repoTablesRef.current = repoTables + + const check = useCallback( + (projectId: string): AutomationCreateDestinationCheck => { + if (!captured) { + return { ok: false, notice: ownerNotice(choiceMessage(reason)) } + } + const revalidated = revalidateAutomationCreateDestination(captured, entriesRef.current) + if (revalidated.status === 'stale') { + return { + ok: false, + notice: { + message: translate( + 'auto.components.automations.createDestination.stale', + '{host} changed while this form was open. Choose it again before saving.' + ).replace('{host}', revalidated.entry.label), + recovery: 'retry', + severity: 'owner' + } + } + } + if (revalidated.status !== 'ready') { + return { ok: false, notice: ownerNotice(choiceMessage(revalidated.reason)) } + } + if (automationCreateProjectMismatch(repoTablesRef.current, revalidated, projectId)) { + return { + ok: false, + notice: ownerNotice( + translate( + 'auto.components.automations.createDestination.projectMismatch', + 'That project is not on {host}. Choose a project on that host, or another host.' + ).replace('{host}', revalidated.entry.label) + ) + } + } + return { ok: true, destination: revalidated } + }, + [captured, reason] + ) + + return { + control: useMemo(() => ({ entries, resolution, onSelect }), [entries, onSelect, resolution]), + check + } +} diff --git a/src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts b/src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts new file mode 100644 index 00000000000..54924f3791c --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-desktop-ssh-registrations.ts @@ -0,0 +1,17 @@ +import { useAppStore } from '../../store' + +/** + * Desktop SSH *registration* generations, mirrored by the store slice. + * + * Only the generation makes an SSH host fenceable, so without it every desktop + * SSH entry projects as view-only. A target whose generation is unknown is + * absent from the map rather than defaulted, which degrades that one host to + * view-only instead of fencing it against a guessed registration. + * + * Reads the mirror rather than `ssh.listTargets()`: the store is already fed by + * every path that changes the target set, so a per-mount IPC round trip bought + * nothing and went stale the moment a host was added or removed behind it. + */ +export function useAutomationDesktopSshRegistrations(): ReadonlyMap { + return useAppStore((s) => s.sshTargetGenerations) +} diff --git a/src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx new file mode 100644 index 00000000000..bbe6c96a85c --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx @@ -0,0 +1,199 @@ +// @vitest-environment happy-dom + +/** + * Same-id SSH re-adoption on the desktop authority. + * + * This is the one catalog change that alters nothing the apply policy used to + * look at: the stable key is incarnation-free, the state stays authoritative, + * the query support stays scoped, the owner stays present and the host stays up + * — only the registration generation moves. The desktop's connection generation + * is a constant and can never reject anything either, so if the apply does not + * run, rows fetched against the previous registration both render and become the + * captured owners that fence later mutations. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationListScopeSelector } from '../../../../shared/automation-list-scope' +import type { AppState } from '@/store' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' +import { resetAutomationHostCatalogGenerationsForTests } from './automation-host-catalog-generation' + +const mocks = vi.hoisted(() => ({ state: {} as Record })) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: Partial) => unknown): unknown => + selector(mocks.state as Partial) + useAppStore.getState = (): Partial => mocks.state as Partial + return { useAppStore } +}) + +import { useAutomationHostCatalog } from './use-automation-host-catalog' + +const TARGET_ID = 'ssh-1' +const SSH_STABLE_KEY = `host:desktop:ssh:${TARGET_ID}` +const DESKTOP_AUTHORITY_KEY = 'authority:desktop' + +function storeState(targetGeneration: number): Record { + const noop = (): void => undefined + return { + repos: [], + settings: null, + sshConnectionStates: new Map([[TARGET_ID, { status: 'connected' }]]), + sshTargetLabels: new Map([[TARGET_ID, 'Build box']]), + sshTargetGenerations: new Map([[TARGET_ID, targetGeneration]]), + removedSshTargetLabels: new Map(), + sshTargetsHydrated: true, + sshStateByEnvironment: new Map(), + runtimeEnvironments: [], + runtimeEnvironmentCatalogSettled: true, + runtimeStatusByEnvironmentId: new Map(), + automationHostFilter: { kind: 'all' }, + setAutomationHostFilter: noop, + openSettingsPage: noop, + openSettingsTarget: noop + } +} + +/** One row per registration generation, so a displayed row names the incarnation it came from. */ +function scopedResponse(selector: AutomationListScopeSelector): unknown { + if (selector.kind !== 'ssh') { + return { automations: [], items: [], orphanCount: 0 } + } + const id = `a-gen${selector.expectedTargetGeneration}` + return { + automations: [{ id, name: id } as Automation], + items: [ + { + automationId: id, + selector: { + kind: 'ssh', + targetId: selector.targetId, + targetGeneration: selector.expectedTargetGeneration + } + } + ], + orphanCount: 0 + } +} + +const scopeRequests: AutomationListScopeSelector[] = [] +/** Generation whose answer is held in flight; null lets every request answer at once. */ +let heldGeneration: number | null = null +let releaseHeld: (() => void) | null = null + +function listScoped({ selector }: { selector: AutomationListScopeSelector }): Promise { + scopeRequests.push(selector) + if (selector.kind === 'ssh' && selector.expectedTargetGeneration === heldGeneration) { + return new Promise((resolve) => { + releaseHeld = () => resolve(scopedResponse(selector)) + }) + } + return Promise.resolve(scopedResponse(selector)) +} + +const roots: Root[] = [] +let root: Root | null = null +let view: AutomationHostCatalogView | null = null + +function Harness(): null { + view = useAutomationHostCatalog() + return null +} + +async function flush(): Promise { + await act(async () => { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } + }) +} + +async function renderWithGeneration(targetGeneration: number): Promise { + mocks.state = storeState(targetGeneration) + if (!root) { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + roots.push(root) + } + const current = root + await act(async () => { + current.render() + }) + await flush() +} + +function sshRequestGenerations(): number[] { + return scopeRequests + .filter((selector) => selector.kind === 'ssh') + .map((selector) => (selector as { expectedTargetGeneration: number }).expectedTargetGeneration) +} + +function sshHostAutomationIds(): string[] { + const group = view?.rows.groups.find((entry) => entry.authorityKey === DESKTOP_AUTHORITY_KEY) + const host = group?.hosts.find((candidate) => candidate.entry.stableKey === SSH_STABLE_KEY) + return (host?.rows ?? []).map((row) => row.automation.id) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + resetAutomationHostCatalogGenerationsForTests() + view = null + root = null + scopeRequests.length = 0 + heldGeneration = null + releaseHeld = null + Object.assign(window, { + api: { + automations: { listScoped: vi.fn(listScoped) }, + ssh: { connect: vi.fn() }, + runtimeEnvironments: { connect: vi.fn() } + } + }) +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((item) => item.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('useAutomationHostCatalog same-id SSH re-adoption', () => { + it('discards a scoped answer captured before the re-adoption', async () => { + heldGeneration = 4 + await renderWithGeneration(4) + expect(sshRequestGenerations()).toEqual([4]) + + await renderWithGeneration(5) + heldGeneration = null + releaseHeld?.() + await flush() + + expect(sshHostAutomationIds()).not.toContain('a-gen4') + expect(sshHostAutomationIds()).toEqual(['a-gen5']) + }) + + it('re-fetches the re-adopted host inside the cache TTL', async () => { + await renderWithGeneration(4) + expect(sshHostAutomationIds()).toEqual(['a-gen4']) + + await renderWithGeneration(5) + + expect(sshRequestGenerations()).toEqual([4, 5]) + expect(sshHostAutomationIds()).toEqual(['a-gen5']) + }) + + it('does not keep the previous registration’s rows on screen while the re-fetch runs', async () => { + await renderWithGeneration(4) + expect(sshHostAutomationIds()).toEqual(['a-gen4']) + + heldGeneration = 5 + await renderWithGeneration(5) + + expect(sshHostAutomationIds()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts b/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts new file mode 100644 index 00000000000..691ef585092 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog-source-state.ts @@ -0,0 +1,85 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { + AutomationCatalogRuntimeSource, + AutomationCatalogSshSource, + AutomationHostCatalogSource +} from './automation-host-catalog-source' + +/** + * Reads the mirrored SSH and runtime state the catalog projects from. + * + * Split out of `use-automation-host-catalog` so the store subscriptions stay in + * one place: every map here is a live store reference whose identity changes on + * each write, and the catalog memo below depends on all of them. + */ + +type AutomationHostCatalogSourceStateInput = { + /** Desktop SSH registration generations, mirrored by the store alongside the maps below. */ + desktopSshGenerations: ReadonlyMap + runtimeEnvironments: readonly PublicKnownRuntimeEnvironment[] +} + +export type AutomationHostCatalogSourceState = Pick< + AutomationHostCatalogSource, + 'desktopSsh' | 'runtimes' | 'runtimeCatalogSettled' +> + +export function useAutomationHostCatalogSourceState({ + desktopSshGenerations, + runtimeEnvironments +}: AutomationHostCatalogSourceStateInput): AutomationHostCatalogSourceState { + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const removedSshTargetLabels = useAppStore((s) => s.removedSshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshTargetsHydrated = useAppStore((s) => s.sshTargetsHydrated) + const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const runtimeCatalogSettled = useAppStore((s) => s.runtimeEnvironmentCatalogSettled) + + const desktopSsh = useMemo( + (): AutomationCatalogSshSource => ({ + targetsHydrated: sshTargetsHydrated, + targetLabels: sshTargetLabels, + targetGenerations: desktopSshGenerations, + removedTargetLabels: removedSshTargetLabels, + connectionStates: sshConnectionStates + }), + [ + desktopSshGenerations, + removedSshTargetLabels, + sshConnectionStates, + sshTargetLabels, + sshTargetsHydrated + ] + ) + + const runtimes = useMemo( + (): AutomationCatalogRuntimeSource[] => + runtimeEnvironments.map((environment) => { + const bucket = sshStateByEnvironment.get(environment.id) + const status = runtimeStatusByEnvironmentId.get(environment.id) + return { + environmentId: environment.id, + label: environment.name, + // Mirrors the owner ref's own rule: creation time stands in until the + // server reports a revision, so a re-pair still changes the value. + pairingRevision: environment.pairingRevision ?? environment.createdAt, + status: status ? { status: status.status } : undefined, + ssh: bucket + ? { + targetsHydrated: bucket.targetsHydrated, + targetLabels: bucket.targetLabels, + targetGenerations: bucket.targetGenerations, + removedTargetLabels: bucket.removedTargetLabels, + connectionStates: bucket.connectionStates + } + : undefined + } + }), + [runtimeEnvironments, runtimeStatusByEnvironmentId, sshStateByEnvironment] + ) + + return { desktopSsh, runtimes, runtimeCatalogSettled } +} diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx new file mode 100644 index 00000000000..5845d80acef --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog.test.tsx @@ -0,0 +1,199 @@ +// @vitest-environment happy-dom + +/** + * Drives the real partition context the hook builds, not a stand-in for it. + * + * The legacy path is where an old runtime's unscoped list is classified on this + * client, so the evidence that classifies it has to be the answering authority's + * own project table. A test that supplied its own context would pass while the + * hook kept handing the desktop's table to every authority, so the only mocks + * here are the transports. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AppState } from '@/store' +import type { Repo } from '../../../../shared/repo-types' +import type { AutomationHostCatalogView } from './use-automation-host-catalog' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + callRuntimeRpc: vi.fn(), + getRuntimeEnvironmentStatus: vi.fn() +})) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: Partial) => unknown): unknown => + selector(mocks.state as Partial) + useAppStore.getState = (): Partial => mocks.state as Partial + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args), + getRuntimeEnvironmentStatus: (...args: unknown[]) => mocks.getRuntimeEnvironmentStatus(...args), + hasRuntimeRpcErrorCode: () => false +})) + +import { useAutomationHostCatalog } from './use-automation-host-catalog' + +const ENVIRONMENT_ID = 'env-1' +const PROJECT_ID = 'shared-project' +const RUNTIME_AUTHORITY_KEY = `authority:runtime:${ENVIRONMENT_ID}` + +function repo(overrides: Partial & { id: string }): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 0, + ...overrides + } as Repo +} + +function legacyAutomation(): Automation { + return { + id: 'a-legacy', + name: 'Nightly on the build box', + prompt: 'go', + precheck: null, + agentId: 'claude', + projectId: PROJECT_ID, + 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 + } as Automation +} + +function storeState(repos: readonly Repo[]): Record { + const noop = (): void => undefined + return { + repos, + settings: null, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + sshTargetGenerations: new Map(), + removedSshTargetLabels: new Map(), + sshTargetsHydrated: true, + sshStateByEnvironment: new Map(), + runtimeEnvironments: [ + { id: ENVIRONMENT_ID, name: 'Build box', createdAt: 1, pairingRevision: 1 } + ], + runtimeEnvironmentCatalogSettled: true, + // No host-scope capability: this is the New-desktop/Old-runtime matrix row. + runtimeStatusByEnvironmentId: new Map([[ENVIRONMENT_ID, { status: { capabilities: [] } }]]), + automationHostFilter: { kind: 'all' }, + setAutomationHostFilter: noop, + openSettingsPage: noop, + openSettingsTarget: noop + } +} + +const roots: Root[] = [] +let view: AutomationHostCatalogView | null = null + +function Harness(): null { + view = useAutomationHostCatalog() + return null +} + +async function renderCatalog(repos: readonly Repo[]): Promise { + mocks.state = storeState(repos) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render() + }) + // The legacy answer commits through the cache and re-renders the view. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +/** What the Runtime + Self row group actually shows for the old runtime. */ +function runtimeSelfAutomationIds(): string[] { + const group = view?.rows.groups.find((entry) => entry.authorityKey === RUNTIME_AUTHORITY_KEY) + const host = group?.hosts.find((candidate) => candidate.entry.kind === 'self') + return (host?.rows ?? []).map((row) => row.automation.id) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + view = null + mocks.callRuntimeRpc.mockReset() + mocks.getRuntimeEnvironmentStatus.mockReset() + mocks.callRuntimeRpc.mockResolvedValue({ automations: [legacyAutomation()] }) + mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [] }) + Object.assign(window, { + api: { + automations: { + listScoped: vi.fn().mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + }, + ssh: { connect: vi.fn() }, + runtimeEnvironments: { connect: vi.fn() } + } + }) +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('useAutomationHostCatalog orphan bootstrap', () => { + it('projects the orphan entry a host answer reported', async () => { + window.api.automations.listScoped = vi + .fn() + .mockResolvedValue({ automations: [], items: [], orphanCount: 2 }) + + await renderCatalog([]) + + // Only a committed answer can report it, so the catalog has to re-derive on + // the cache; nothing in the store changes when an orphan count arrives. + expect(view?.catalog.entries.map((entry) => entry.stableKey)).toContain('host:desktop:orphan') + }) +}) + +describe('useAutomationHostCatalog legacy partitioning', () => { + it('does not let a same-id desktop project claim an old runtime record for Self', async () => { + await renderCatalog([repo({ id: PROJECT_ID })]) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: ENVIRONMENT_ID }, + 'automation.list', + null, + expect.anything() + ) + expect(runtimeSelfAutomationIds()).toEqual([]) + }) + + it('classifies the record as Self from the runtime’s own mirrored project', async () => { + await renderCatalog([ + repo({ id: PROJECT_ID, executionHostId: `runtime:${ENVIRONMENT_ID}` }), + // Listed last so a bare-ID table would answer with this desktop SSH repo. + repo({ id: PROJECT_ID, connectionId: 'devbox' }) + ]) + + expect(runtimeSelfAutomationIds()).toEqual(['a-legacy']) + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts new file mode 100644 index 00000000000..ae646a3db9c --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' +import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' +import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { buildAutomationHostCatalog } from './automation-host-catalog' +import { buildAutomationHostCatalogSource } from './automation-host-catalog-source' +import { + automationHostLoadCounts, + withAutomationHostCacheHealth, + type AutomationHostLoadCounts +} from './automation-host-cache-health' +import { + createAutomationHostQueryController, + type AutomationHostQueryController +} from './automation-host-cache-controller' +import { automationHostCatalogEntryFingerprint } from './automation-host-catalog-generation' +import type { AutomationAuthorityChangeEvent } from './automation-host-invalidation' +import type { + AutomationHostCatalog, + AutomationHostCatalogEntry +} from './automation-host-catalog-types' +import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter' +import { + resolveAutomationHostFilter, + type AutomationHostFilterResolution +} from './automation-host-filter-resolution' +import { + resolveAutomationHostListRows, + type AutomationHostListRows +} from './automation-host-list-rows' +import { automationHostFetchTarget } from './automation-host-fetch-target' +import { + runAutomationHostRecovery, + type AutomationHostRecoveryDeps +} from './automation-host-recovery' +import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import { + automationAuthorityPartitionContext, + automationRuntimePairingRevision, + groupReposByAutomationAuthority +} from './automation-authority-identity' +import { useAutomationDesktopSshRegistrations } from './use-automation-desktop-ssh-registrations' +import { useAutomationHostCatalogSourceState } from './use-automation-host-catalog-source-state' + +/** + * Owns the per-host query controller and the two catalogs the page needs. + * + * There are deliberately two: the one applied to the controller is derived from + * mirrored store state only, because applying a catalog advances generations and + * cancels in-flight work, and a catalog derived from query results would do that + * every time a query landed. The display catalog folds those results back in. + */ + +export type AutomationHostCatalogView = { + catalog: AutomationHostCatalog + entries: readonly AutomationHostCatalogEntry[] + resolution: AutomationHostFilterResolution + rows: AutomationHostListRows + loadCounts: AutomationHostLoadCounts + selectHost: (filter: AutomationHostFilter) => void + /** Runs the recovery verb the notice or empty state offered for a host. */ + recover: (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => void + /** Manual refresh of every host in view, bypassing TTL where reachable. */ + refreshHosts: () => void + /** + * Invalidates the host a local write just landed on, without waiting for the + * authority's own event. The event still arrives and is harmless; what this + * removes is the round trip between the save returning and the list agreeing. + */ + notifyAuthorityChange: (event: AutomationAuthorityChangeEvent) => void +} + +export type AutomationHostCatalogOptions = { + /** Stable keys a stored automation or persisted filter still points at. */ + referencedStableKeys?: Iterable + /** Authorities whose most recent unscoped list attempt failed. */ + failedAuthorityKeys?: ReadonlySet +} + +export function useAutomationHostCatalog( + options: AutomationHostCatalogOptions = {} +): AutomationHostCatalogView { + const settings = useAppStore((s) => s.settings) + const repos = useAppStore((s) => s.repos) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const automationHostFilter = useAppStore((s) => s.automationHostFilter) + const setAutomationHostFilter = useAppStore((s) => s.setAutomationHostFilter) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const desktopSshGenerations = useAutomationDesktopSshRegistrations() + + const desktopLabel = useMemo( + () => getHostDisplayLabelOverrides(settings).get('local') ?? getLocalExecutionHostLabel(), + [settings] + ) + // Why a ref: the controller outlives every render, so its legacy-partition + // callback must read the latest repo tables, not the ones it was created with. + const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) + const repoTablesRef = useRef(repoTables) + repoTablesRef.current = repoTables + const [controller] = useState(() => + createAutomationHostQueryController({ + // Scoped to the answering authority: another host's identically named repo + // is not evidence about this one, and its absence is not evidence either. + legacyPartitionContext: (authority) => + automationAuthorityPartitionContext(repoTablesRef.current, authority) + }) + ) + + const [cacheVersion, setCacheVersion] = useState(0) + useEffect(() => { + return controller.cache.subscribe(() => setCacheVersion((version) => version + 1)) + }, [controller]) + useEffect(() => () => controller.dispose(), [controller]) + + const orphanCount = useCallback( + (authority: StableAutomationAuthorityRef) => controller.authorityOrphanCount(authority), + [controller] + ) + const sourceState = useAutomationHostCatalogSourceState({ + desktopSshGenerations, + runtimeEnvironments + }) + const selectedStableKey = automationHostFilterStableKey(automationHostFilter) + // The saved selection references itself: without this the catalog would omit a + // removed host the user is still filtered to, and absence would read as removal. + const referencedStableKeys = useMemo( + () => + new Set([ + ...(options.referencedStableKeys ?? []), + ...(selectedStableKey ? [selectedStableKey] : []) + ]), + [options.referencedStableKeys, selectedStableKey] + ) + const queryCatalog = useMemo( + () => + buildAutomationHostCatalog( + buildAutomationHostCatalogSource({ + ...sourceState, + desktopLabel, + orphanCount, + referencedStableKeys + }) + ), + [desktopLabel, orphanCount, referencedStableKeys, sourceState] + ) + + // Why a signature rather than the catalog object: health and label changes + // re-derive the catalog but must not re-apply it, since applying cancels work. + // The generation fingerprint is embedded whole rather than summarised, so a + // change the commit fence would reject can never fail to re-apply here. + const applySignature = useMemo( + () => + [ + selectedStableKey ?? '', + ...queryCatalog.entries.map((entry) => + [ + automationHostCatalogEntryFingerprint(entry), + entry.querySupport, + entry.authorityHealth === 'unavailable' ? 'down' : 'up' + ].join(':') + ) + ].join('\n'), + [queryCatalog, selectedStableKey] + ) + useEffect(() => { + void controller.applyCatalog(queryCatalog, { selectedStableKey }) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- applySignature is the apply policy: it covers the generation fingerprint plus every other field the scheduler reads, and keying off the catalog object would re-apply (and cancel in-flight work) on a label or health change. + }, [applySignature, controller]) + + const catalog = useMemo( + () => + withAutomationHostCacheHealth(queryCatalog, { + entry: (stableKey) => controller.cache.getByKey(stableKey), + ...(options.failedAuthorityKeys ? { failedAuthorityKeys: options.failedAuthorityKeys } : {}) + }), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. + [cacheVersion, controller, options.failedAuthorityKeys, queryCatalog] + ) + const resolution = useMemo( + () => + resolveAutomationHostFilter({ + filter: automationHostFilter, + catalog, + // Deliberately not `referencedStableKeys`: that set includes the saved + // selection so the catalog still projects it, and a selection that + // referenced itself could never be dropped when its host really goes. + referencedStableKeys: new Set([ + ...(options.referencedStableKeys ?? []), + ...catalog.entries + .filter((entry) => (controller.cache.getByKey(entry.stableKey)?.data.length ?? 0) > 0) + .map((entry) => entry.stableKey) + ]) + }), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. + [automationHostFilter, cacheVersion, catalog, controller, options.referencedStableKeys] + ) + const rows = useMemo( + () => + resolveAutomationHostListRows({ + catalog, + resolution, + entry: (stableKey) => controller.cache.getByKey(stableKey) + }), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- cacheVersion is the cache's change signal; the cache object itself never changes identity. + [cacheVersion, catalog, controller, resolution] + ) + + const pairingRevision = useCallback( + (environmentId: string) => automationRuntimePairingRevision(runtimeEnvironments, environmentId), + [runtimeEnvironments] + ) + const recoveryDeps = useMemo( + (): AutomationHostRecoveryDeps => ({ + retry: (entry) => { + void controller.scheduler.retry(automationHostFetchTarget(entry, pairingRevision)) + }, + connectSshTarget: (targetId) => { + void window.api.ssh.connect({ targetId }) + }, + connectRuntimeEnvironment: (environmentId) => { + void window.api.runtimeEnvironments.connect({ selector: environmentId }) + }, + openSettings: (target) => { + openSettingsPage() + openSettingsTarget(target) + } + }), + [controller, openSettingsPage, openSettingsTarget, pairingRevision] + ) + + const recover = useCallback( + (action: AutomationHostRecoveryAction, entry?: AutomationHostCatalogEntry | null) => { + runAutomationHostRecovery(action, entry ?? resolution.entry, recoveryDeps) + }, + [recoveryDeps, resolution.entry] + ) + const refreshHosts = useCallback(() => { + void controller.applyCatalog(queryCatalog, { selectedStableKey, force: true }) + }, [controller, queryCatalog, selectedStableKey]) + + return { + catalog, + entries: catalog.entries, + resolution, + rows, + loadCounts: useMemo(() => automationHostLoadCounts(catalog), [catalog]), + selectHost: setAutomationHostFilter, + recover, + refreshHosts, + notifyAuthorityChange: controller.handleAuthorityEvent + } +} diff --git a/src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx b/src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx new file mode 100644 index 00000000000..186b04ae971 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom + +/** + * The hook, not the pure resolver: what it repairs depends on where focus went + * after it left a row, and only a real DOM can answer that. + */ + +import { act, useRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAutomationListFocusRecovery } from './use-automation-list-focus-recovery' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function Harness({ rowKeys }: { rowKeys: readonly string[] }): React.JSX.Element { + const listRef = useRef(null) + const pickerRef = useRef(null) + useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: pickerRef }) + return ( +
+ {/* The header: outside the list container, exactly as the panel lays it out. */} +
+
+ +
+ {rowKeys.map((rowKey) => ( +
+ ))} +
+
+ ) +} + +function render(rowKeys: readonly string[]): void { + act(() => { + root.render() + }) +} + +function node(testId: string): HTMLElement { + const found = container.querySelector(`[data-testid="${testId}"]`) + if (!found) { + throw new Error(`no node for ${testId}`) + } + return found +} + +function focus(testId: string): void { + act(() => { + node(testId).focus() + }) +} + +describe('useAutomationListFocusRecovery', () => { + it('leaves focus in the search field when filtering removes the row it was on', () => { + render(['r1', 'r2', 'r3']) + focus('r2') + focus('search') + const search = node('search') + + render(['r1', 'r3']) + + expect(document.activeElement).toBe(search) + }) + + it('still repairs focus lost from the list itself', () => { + render(['r1', 'r2', 'r3']) + focus('r2') + act(() => { + node('r2').blur() + }) + + render(['r1', 'r3']) + + expect(document.activeElement).toBe(node('r3')) + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-list-focus-recovery.ts b/src/renderer/src/components/automations/use-automation-list-focus-recovery.ts new file mode 100644 index 00000000000..d7f5b290dcd --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-focus-recovery.ts @@ -0,0 +1,88 @@ +import { useEffect, useRef } from 'react' +import { resolveAutomationListFocusRecovery } from './automation-list-focus-recovery' + +/** + * Keeps keyboard focus inside the list when the row it was on disappears. + * + * The focused row has to be recorded as the user moves through the list, not + * read when the change lands: by then the row is unmounted and the browser has + * already dropped focus to ``, which is exactly the state this repairs. + * Focus is only moved when it was in this list and was actually lost — a user + * who has tabbed away is left alone. + */ + +export const AUTOMATION_ROW_ID_ATTRIBUTE = 'data-automation-row-id' + +export type AutomationListFocusRecoveryOptions = { + rowKeys: readonly string[] + containerRef: React.RefObject + /** Focused as the last resort — it is the control that produced this list. */ + fallbackRef: React.RefObject +} + +function focusRow(container: HTMLElement, rowKey: string): boolean { + const selector = `[${AUTOMATION_ROW_ID_ATTRIBUTE}="${CSS.escape(rowKey)}"]` + const row = container.querySelector(selector) + row?.focus() + return row !== null +} + +/** The fallback is a wrapper, so focus the control inside it rather than the box. */ +function focusFallback(fallback: HTMLElement | null): void { + const focusable = fallback?.querySelector('button, [tabindex]:not([tabindex="-1"])') + ;(focusable ?? fallback)?.focus() +} + +export function useAutomationListFocusRecovery({ + rowKeys, + containerRef, + fallbackRef +}: AutomationListFocusRecoveryOptions): void { + const focusedRowKeyRef = useRef(null) + const previousRowKeysRef = useRef(rowKeys) + + // Document-level: focus moving to the search field or any other control outside + // the list must erase the memory, and that event never reaches the container. + useEffect(() => { + const onFocusIn = (event: FocusEvent): void => { + const container = containerRef.current + const target = event.target + if (!container || !(target instanceof HTMLElement) || !container.contains(target)) { + focusedRowKeyRef.current = null + return + } + focusedRowKeyRef.current = + target + .closest(`[${AUTOMATION_ROW_ID_ATTRIBUTE}]`) + ?.getAttribute(AUTOMATION_ROW_ID_ATTRIBUTE) ?? null + } + document.addEventListener('focusin', onFocusIn) + return () => document.removeEventListener('focusin', onFocusIn) + }, [containerRef]) + + useEffect(() => { + const previousRowKeys = previousRowKeysRef.current + previousRowKeysRef.current = rowKeys + const container = containerRef.current + if (!container) { + return + } + // Focus still somewhere in the list means nothing was lost to repair. + if (container.contains(document.activeElement)) { + return + } + const target = resolveAutomationListFocusRecovery({ + previousRowKeys, + nextRowKeys: rowKeys, + focusedRowKey: focusedRowKeyRef.current + }) + if (!target) { + return + } + focusedRowKeyRef.current = null + if (target.kind === 'row' && focusRow(container, target.rowKey)) { + return + } + focusFallback(fallbackRef.current) + }, [rowKeys, containerRef, fallbackRef]) +} diff --git a/src/renderer/src/components/automations/use-automation-list-search.test.tsx b/src/renderer/src/components/automations/use-automation-list-search.test.tsx new file mode 100644 index 00000000000..0dffee859c1 --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-search.test.tsx @@ -0,0 +1,199 @@ +// @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 type { Repo } from '../../../../shared/repo-types' +import { makeAutomation, makeAutomationListRow, REPO_ID } from './automations-page-fixtures' +import type { AutomationListRow } from './automation-list-row-identity' +import { resolveAutomationListEmptyState } from './automation-list-empty-state' +import type { AutomationHostFilterResolution } from './automation-host-filter-resolution' +import * as searchRows from './automation-list-search-rows' +import { useAutomationListSearch } from './use-automation-list-search' + +// Why: the 50 ms budget is a claim about how often indexing runs, so the test +// counts index builds instead of timing them (a wall clock would flake in CI). +vi.mock('./automation-list-search-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + buildAutomationListSearchRows: vi.fn(actual.buildAutomationListSearchRows), + matchAutomationListSearchRowKeys: vi.fn(actual.matchAutomationListSearchRowKeys) + } +}) + +const buildRowsSpy = vi.mocked(searchRows.buildAutomationListSearchRows) +const matchRowsSpy = vi.mocked(searchRows.matchAutomationListSearchRowKeys) + +const repoMap = new Map([ + [REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo] +]) + +type SearchResult = ReturnType + +let container: HTMLDivElement +let root: Root +let latest: SearchResult | null = null + +function Harness({ query, rows }: { query: string; rows: readonly AutomationListRow[] }): null { + latest = useAutomationListSearch({ + listSearchQuery: query, + rows, + externalAutomationEntries: [], + repoMap, + selectedRowKey: null, + selectedExternalKey: null, + selectAutomationRow: () => undefined, + selectExternalKey: () => undefined + }) + return null +} + +function render(query: string, rows: readonly AutomationListRow[]): void { + act(() => { + root.render() + }) +} + +function makeRows(count: number): AutomationListRow[] { + return Array.from({ length: count }, (_, index) => + makeAutomationListRow({ + automation: makeAutomation({ + id: `a-${index}`, + name: `Automation ${index}`, + prompt: `sweep repo ${index}` + }) + }) + ) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + buildRowsSpy.mockClear() + matchRowsSpy.mockClear() +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + latest = null +}) + +describe('useAutomationListSearch index shape', () => { + it('indexes the row set once and never again per keystroke', () => { + render('', makeRows(20)) + const buildsAfterMount = buildRowsSpy.mock.calls.length + + // Fresh arrays per keystroke: a refresh tick mid-typing must not re-index. + for (const query of ['s', 'sw', 'swe', 'swee', 'sweep']) { + render(query, makeRows(20)) + } + + expect(buildRowsSpy.mock.calls.length).toBe(buildsAfterMount) + expect(latest?.filteredRows).toHaveLength(20) + }) + + it('reuses the index when a refresh tick replaces rows with equal search content', () => { + render('', makeRows(5)) + const buildsAfterMount = buildRowsSpy.mock.calls.length + + // New objects, new array, same searchable content — a nextRunAt-style tick. + render( + '', + makeRows(5).map((row) => ({ + ...row, + automation: { ...row.automation, nextRunAt: Date.now() } + })) + ) + + expect(buildRowsSpy.mock.calls.length).toBe(buildsAfterMount) + }) + + it('rebuilds once when searchable content actually changes', () => { + const automations = makeRows(5) + render('', automations) + const buildsAfterMount = buildRowsSpy.mock.calls.length + + const renamed = automations.map((row, index) => + index === 0 ? { ...row, automation: { ...row.automation, name: 'Renamed sweep' } } : row + ) + render('', renamed) + + expect(buildRowsSpy.mock.calls.length).toBe(buildsAfterMount + 1) + }) + + it('matches in a single pass over prebuilt indexes per settled query', () => { + const automations = makeRows(10) + render('', automations) + matchRowsSpy.mockClear() + + render('sweep repo 3', automations) + + // Two row sets (local + external), matched once each for the settled query. + expect(matchRowsSpy.mock.calls.length).toBeLessThanOrEqual(2) + expect(latest?.filteredRows.map((row) => row.automation.id)).toEqual(['a-3']) + }) +}) + +describe('useAutomationListSearch cross-host identity', () => { + it('keeps both hosts’ copies of one automation ID through a matching query', () => { + // Same ID under two authorities is legal (doc:38). Reconstructing the + // filtered list from a bare-ID map silently returns one host's copy twice + // and drops the other host's row from its group. + const collided = [ + makeAutomationListRow({ + automation: makeAutomation({ id: 'a-1', name: 'Nightly desktop' }) + }), + makeAutomationListRow({ + automation: makeAutomation({ id: 'a-1', name: 'Nightly web-01' }), + hostStableKey: 'host:runtime:gpu:ssh:web-01', + hostLabel: 'web-01' + }) + ] + render('nightly', collided) + + expect(latest?.filteredRows.map((row) => row.automation.name)).toEqual([ + 'Nightly desktop', + 'Nightly web-01' + ]) + }) +}) + +describe('useAutomationListSearch counts', () => { + it('reports rows before and after search for the empty-state view', () => { + const automations = makeRows(4) + render('', automations) + expect(latest?.searchCounts).toEqual({ + hostRowCount: 4, + visibleRowCount: 4, + searchActive: false + }) + + render('no-such-automation', automations) + expect(latest?.searchCounts).toEqual({ + hostRowCount: 4, + visibleRowCount: 0, + searchActive: true + }) + }) + + it('feeds the no-match empty state instead of letting it recompute counts', () => { + const resolution: AutomationHostFilterResolution = { + effective: { kind: 'all' }, + entry: null, + status: 'all', + announceFallback: false + } + render('no-such-automation', makeRows(3)) + const counts = latest?.searchCounts + if (!counts) { + throw new Error('hook produced no counts') + } + + expect( + resolveAutomationListEmptyState({ resolution, externalManagersListed: false, ...counts }).kind + ).toBe('search-no-match') + }) +}) diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts index 95ac89262a0..435e534962e 100644 --- a/src/renderer/src/components/automations/use-automation-list-search.ts +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -1,45 +1,72 @@ -import { useDeferredValue, useEffect, useMemo } from 'react' -import type { Automation } from '../../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { useDeferredValue, useEffect, useMemo, useRef } from 'react' import type { Repo } from '../../../../shared/repo-types' +import { resolveAutomationListSearchQuery } from './automation-list-search' +import type { AutomationListRow } from './automation-list-row-identity' import { - automationListSearchIndexMatches, - buildAutomationListSearchIndex, - buildAutomationProjectSearchText, - resolveAutomationListSearchQuery, - truncateAutomationListSearchField, - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, - type AutomationListSearchFields, - type AutomationListSearchIndex -} from './automation-list-search' + buildAutomationListSearchRowFingerprint, + buildAutomationListSearchRows, + buildAutomationSearchRowSources, + buildExternalAutomationSearchRowSources, + matchAutomationListSearchRowKeys, + type AutomationListSearchRow, + type AutomationListSearchRowSource, + type AutomationWorkspaceNameLookup +} from './automation-list-search-rows' import type { ExternalAutomationListEntry } from './external-automation-list-entries' -import { getExternalProviderLabel } from './external-automation-display' + +/** Counts the empty-state view consumes, so it never recomputes what search already knows. */ +export type AutomationListSearchCounts = { + hostRowCount: number + visibleRowCount: number + searchActive: boolean +} + +/** + * Rebuilds indexes only when the fingerprint changes, so a refresh tick that + * replaces arrays with equal search content re-renders without re-indexing. + * The cache is keyed by fingerprint rather than array identity, which useMemo + * cannot express on its own. + */ +function useAutomationSearchRows( + sources: AutomationListSearchRowSource[] +): AutomationListSearchRow[] { + const fingerprint = useMemo(() => buildAutomationListSearchRowFingerprint(sources), [sources]) + const cacheRef = useRef<{ fingerprint: string; rows: AutomationListSearchRow[] } | null>(null) + if (!cacheRef.current || cacheRef.current.fingerprint !== fingerprint) { + cacheRef.current = { fingerprint, rows: buildAutomationListSearchRows(sources) } + } + return cacheRef.current.rows +} export function useAutomationListSearch({ listSearchQuery, - automations, + rows, externalAutomationEntries, repoMap, - selectedId, + worktreeMap, + selectedRowKey, selectedExternalKey, - selectAutomationId, + selectAutomationRow, selectExternalKey }: { listSearchQuery: string - automations: readonly Automation[] + rows: readonly AutomationListRow[] externalAutomationEntries: readonly ExternalAutomationListEntry[] repoMap: ReadonlyMap - selectedId: string | null + worktreeMap?: AutomationWorkspaceNameLookup + /** The row currently on screen, not the bare id: two hosts can hold that id. */ + selectedRowKey: string | null selectedExternalKey: string | null - selectAutomationId: (automationId: string | null) => void + selectAutomationRow: (rowKey: string | null) => void selectExternalKey: (externalKey: string | null) => void }): { isListSearchQueryTooLarge: boolean isListSearchActive: boolean - filteredAutomations: readonly Automation[] + filteredRows: readonly AutomationListRow[] filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] hasListItems: boolean hasFilteredListItems: boolean + searchCounts: AutomationListSearchCounts } { // Why: keep the input snappy; matching is deferred so caret never waits on // index scans. Only the normalized active query can fire a search. @@ -60,127 +87,55 @@ export function useAutomationListSearch({ deferredListSearchResolution.status === 'active' ? deferredListSearchResolution.query : null const isListSearchActive = activeListSearchQuery !== null - // Why: fingerprint includes id + search fields so refresh ticks that only - // change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts - // are truncated to the indexed prefix so each tick stays O(bound) per row. - const automationSearchFingerprint = useMemo( - () => - automations - .map((automation) => { - const repo = repoMap.get(getAutomationRunRepoId(automation)) - const project = buildAutomationProjectSearchText({ - displayName: repo?.displayName, - path: repo?.path - }) - const prompt = truncateAutomationListSearchField( - automation.prompt, - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS - ) - return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}` - }) - .join('\u0000'), - [automations, repoMap] + const automationSearchSources = useMemo( + () => buildAutomationSearchRowSources(rows, { repoMap, worktreeMap }), + [rows, repoMap, worktreeMap] ) - const automationSearchRows = useMemo((): { - id: string - index: AutomationListSearchIndex - }[] => { - return automations.map((automation) => { - const repo = repoMap.get(getAutomationRunRepoId(automation)) - return { - id: automation.id, - index: buildAutomationListSearchIndex({ - name: automation.name, - project: buildAutomationProjectSearchText({ - displayName: repo?.displayName, - path: repo?.path - }), - prompt: automation.prompt - }) - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate - }, [automationSearchFingerprint]) + const automationSearchRows = useAutomationSearchRows(automationSearchSources) - const externalAutomationSearchFingerprint = useMemo( - () => - externalAutomationEntries - .map((entry) => { - const prompt = truncateAutomationListSearchField( - entry.job.prompt ?? entry.job.promptPreview ?? '', - AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS - ) - return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}` - }) - .join('\u0000'), + const externalAutomationSearchSources = useMemo( + () => buildExternalAutomationSearchRowSources(externalAutomationEntries), [externalAutomationEntries] ) - const externalAutomationSearchRows = useMemo((): { - key: string - index: AutomationListSearchIndex - }[] => { - return externalAutomationEntries.map((entry) => { - const fields: AutomationListSearchFields = { - name: entry.job.name, - project: [ - getExternalProviderLabel(entry.manager), - entry.manager.targetLabel, - entry.job.workdir - ] - .filter(Boolean) - .join(' '), - prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' - } - return { key: entry.key, index: buildAutomationListSearchIndex(fields) } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate - }, [externalAutomationSearchFingerprint]) + const externalAutomationSearchRows = useAutomationSearchRows(externalAutomationSearchSources) // Why: matching runs only when the normalized query or search content changes — // never on relativeNow / nextRunAt / usage refresh alone. - const filteredAutomationIds = useMemo((): readonly string[] | null => { - if (activeListSearchQuery === null) { - return null - } - const ids: string[] = [] - for (const row of automationSearchRows) { - if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { - ids.push(row.id) - } - } - return ids - }, [activeListSearchQuery, automationSearchRows]) + const filteredRowKeys = useMemo( + (): readonly string[] | null => + activeListSearchQuery === null + ? null + : matchAutomationListSearchRowKeys(automationSearchRows, activeListSearchQuery), + [activeListSearchQuery, automationSearchRows] + ) - const filteredExternalAutomationKeys = useMemo((): readonly string[] | null => { - if (activeListSearchQuery === null) { - return null - } - const keys: string[] = [] - for (const row of externalAutomationSearchRows) { - if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { - keys.push(row.key) - } - } - return keys - }, [activeListSearchQuery, externalAutomationSearchRows]) + const filteredExternalAutomationKeys = useMemo( + (): readonly string[] | null => + activeListSearchQuery === null + ? null + : matchAutomationListSearchRowKeys(externalAutomationSearchRows, activeListSearchQuery), + [activeListSearchQuery, externalAutomationSearchRows] + ) - const filteredAutomations = useMemo((): readonly Automation[] => { - if (filteredAutomationIds === null) { - return automations + const filteredRows = useMemo((): readonly AutomationListRow[] => { + if (filteredRowKeys === null) { + return rows } - if (filteredAutomationIds.length === 0) { + if (filteredRowKeys.length === 0) { return [] } - const byId = new Map(automations.map((automation) => [automation.id, automation])) - const next: Automation[] = [] - for (const id of filteredAutomationIds) { - const automation = byId.get(id) - if (automation) { - next.push(automation) + // Keyed by row, not automation id: a bare-id map holds one entry for two + // hosts' copies, so one host's row would be dropped and the other doubled. + const byKey = new Map(rows.map((row) => [row.key, row])) + const next: AutomationListRow[] = [] + for (const key of filteredRowKeys) { + const row = byKey.get(key) + if (row) { + next.push(row) } } return next - }, [automations, filteredAutomationIds]) + }, [rows, filteredRowKeys]) const filteredExternalAutomationEntries = useMemo((): readonly ExternalAutomationListEntry[] => { if (filteredExternalAutomationKeys === null) { @@ -200,9 +155,8 @@ export function useAutomationListSearch({ return next }, [externalAutomationEntries, filteredExternalAutomationKeys]) - const hasListItems = automations.length + externalAutomationEntries.length > 0 - const hasFilteredListItems = - filteredAutomations.length + filteredExternalAutomationEntries.length > 0 + const hostRowCount = rows.length + externalAutomationEntries.length + const visibleRowCount = filteredRows.length + filteredExternalAutomationEntries.length // Why: when search hides the current row, move selection to the first visible // match so list highlight and detail stay aligned. No matches → keep detail. @@ -212,21 +166,21 @@ export function useAutomationListSearch({ } const localVisible = selectedExternalKey === null && - selectedId != null && - filteredAutomations.some((automation) => automation.id === selectedId) + selectedRowKey != null && + filteredRows.some((row) => row.key === selectedRowKey) const externalVisible = selectedExternalKey != null && filteredExternalAutomationEntries.some((entry) => entry.key === selectedExternalKey) if (localVisible || externalVisible) { return } - const firstLocal = filteredAutomations[0] + const firstLocal = filteredRows[0] if (firstLocal) { if (selectedExternalKey !== null) { selectExternalKey(null) } - if (selectedId !== firstLocal.id) { - selectAutomationId(firstLocal.id) + if (selectedRowKey !== firstLocal.key) { + selectAutomationRow(firstLocal.key) } return } @@ -236,20 +190,23 @@ export function useAutomationListSearch({ } }, [ activeListSearchQuery, - filteredAutomations, + filteredRows, filteredExternalAutomationEntries, - selectAutomationId, + selectAutomationRow, selectExternalKey, selectedExternalKey, - selectedId + selectedRowKey ]) return { isListSearchQueryTooLarge, isListSearchActive, - filteredAutomations, + filteredRows, filteredExternalAutomationEntries, - hasListItems, - hasFilteredListItems + hasListItems: hostRowCount > 0, + hasFilteredListItems: visibleRowCount > 0, + // Why: the empty-state view reads rows-before and rows-after from here + // rather than recomputing either count from its own props. + searchCounts: { hostRowCount, visibleRowCount, searchActive: isListSearchActive } } } diff --git a/src/renderer/src/components/automations/use-automation-source-host-availability.ts b/src/renderer/src/components/automations/use-automation-source-host-availability.ts new file mode 100644 index 00000000000..24f32ec55cc --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-source-host-availability.ts @@ -0,0 +1,156 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { PreflightStatus } from '../../../../preload/api-types' +import { + getRepoBackedProviderAvailability, + type RuntimeProviderPreflightStatus +} from '../task-source-provider-availability' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import { + getRepoBackedAutomationSourceContext, + getRuntimeSourceHostAvailability, + type RepoBackedAutomationSourceContext +} from './automation-source-context' +import type { AutomationListRow } from './automation-list-row-identity' + +/** + * Availability of each automation's *source* host — the host its task provider + * is authenticated against, which can be a different machine from the one the + * automation runs on. Provider auth and tooling therefore have to be checked + * there, not on the run target. + * + * Each runtime source host is probed once per mount; the result is remembered + * even when the probe fails, so a host that could not be reached reads as + * checked-and-unknown rather than being dialled again on every render. + * + * Keyed by row, not by automation ID: two authorities may hold that ID, and + * their copies can name different source hosts. + */ +export function useAutomationSourceHostAvailability( + rows: readonly AutomationListRow[] +): ReadonlyMap { + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) + const mountedRef = useRef(true) + const requestedHostIdsRef = useRef>(new Set()) + const [statusByHostId, setStatusByHostId] = useState< + ReadonlyMap + >(() => new Map()) + const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey + + const sourceContexts = useMemo( + () => + rows + .map((row) => getRepoBackedAutomationSourceContext(row.automation)) + .filter((context): context is RepoBackedAutomationSourceContext => context !== null), + [rows] + ) + const runtimeSourceHostIds = useMemo(() => { + const hostIds = new Set() + for (const context of sourceContexts) { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + if (getRuntimeSourceHostAvailability(context, runtimeStatusByEnvironmentId)) { + continue + } + hostIds.add(parsed.id) + } + return [...hostIds].sort() + }, [runtimeStatusByEnvironmentId, sourceContexts]) + + useEffect( + () => () => { + mountedRef.current = false + }, + [] + ) + useEffect(() => { + if (!preflightStatusCurrent || !preflightStatusChecked) { + void refreshPreflightStatus() + } + }, [preflightStatusChecked, preflightStatusCurrent, refreshPreflightStatus]) + + useEffect(() => { + const unrequested = runtimeSourceHostIds.filter( + (hostId) => !requestedHostIdsRef.current.has(hostId) + ) + if (unrequested.length === 0) { + return + } + const record = (hostId: TaskSourceContext['hostId'], status: PreflightStatus | null): void => { + if (!mountedRef.current) { + return + } + setStatusByHostId((current) => new Map(current).set(hostId, { checked: true, status })) + } + setStatusByHostId((current) => { + const next = new Map(current) + for (const hostId of unrequested) { + next.set(hostId, { checked: false, status: null }) + } + return next + }) + for (const hostId of unrequested) { + requestedHostIdsRef.current.add(hostId) + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'runtime') { + continue + } + void callRuntimeRpc( + { kind: 'environment', environmentId: parsed.environmentId }, + 'preflight.check', + undefined, + { timeoutMs: 15_000 } + ) + .then((status) => record(hostId, status)) + .catch(() => record(hostId, null)) + } + }, [runtimeSourceHostIds]) + + return useMemo(() => { + const availabilityByRowKey = new Map() + for (const row of rows) { + const context = getRepoBackedAutomationSourceContext(row.automation) + if (!context) { + continue + } + const hostAvailability = getRuntimeSourceHostAvailability( + context, + runtimeStatusByEnvironmentId + ) + const availability = [ + ...(hostAvailability ? [hostAvailability] : []), + ...getRepoBackedProviderAvailability({ + provider: context.provider, + contexts: [context], + preflightStatus, + preflightReady: preflightStatusCurrent && preflightStatusChecked, + runtimePreflightStatusByHostId: statusByHostId + }) + ] + if (availability.length > 0) { + availabilityByRowKey.set(row.key, availability) + } + } + return availabilityByRowKey + }, [ + rows, + preflightStatus, + preflightStatusChecked, + preflightStatusCurrent, + runtimeStatusByEnvironmentId, + statusByHostId + ]) +} diff --git a/src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx b/src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx new file mode 100644 index 00000000000..73e44e5f151 --- /dev/null +++ b/src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx @@ -0,0 +1,140 @@ +// @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 type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { useExternalAutomationScopeRetention } from './use-external-automation-scope-retention' + +const retainExternalScopes = + vi.fn<(request: { owners: readonly AutomationOwnerRef[] }) => Promise>() + +let container: HTMLDivElement +let root: Root + +function entry(overrides: Partial = {}): AutomationHostCatalogEntry { + return { + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + owner: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + stableKey: 'host:desktop:self', + label: 'This computer', + authorityLabel: 'This computer', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped', + ...overrides + } +} + +function sshEntry(targetId: string, targetGeneration = 3): AutomationHostCatalogEntry { + return entry({ + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } + }, + stableKey: `host:desktop:ssh:${targetId}`, + label: targetId, + kind: 'ssh' + }) +} + +const runtimeEntry = entry({ + stableRef: { authority: { kind: 'runtime', environmentId: 'env-1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'env-1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:env-1:self', + label: 'Build box' +}) + +function Harness({ entries }: { entries: readonly AutomationHostCatalogEntry[] }): null { + useExternalAutomationScopeRetention(entries) + return null +} + +function render(entries: readonly AutomationHostCatalogEntry[]): void { + act(() => { + root.render() + }) +} + +function retainedOwners(callIndex: number): readonly AutomationOwnerRef[] { + return retainExternalScopes.mock.calls[callIndex]?.[0].owners ?? [] +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + retainExternalScopes.mockReset() + retainExternalScopes.mockResolvedValue(undefined) + Object.assign(window, { api: { automations: { retainExternalScopes } } }) +}) + +afterEach(() => { + container.remove() +}) + +describe('useExternalAutomationScopeRetention', () => { + it('retains only the hosts whose managers are actually listed', () => { + render([entry(), runtimeEntry, sshEntry('t1')]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(1) + expect(retainedOwners(0)).toEqual([ + { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 3 } + } + ]) + act(() => root.unmount()) + }) + + it('does not restart in-flight probes when the same scope set re-renders', () => { + render([entry(), sshEntry('t1')]) + render([entry(), sshEntry('t1')]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(1) + act(() => root.unmount()) + }) + + it('drops a host as soon as the filter moves off it', () => { + render([entry(), sshEntry('t1')]) + render([sshEntry('t1')]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(2) + expect(retainedOwners(1)).toEqual([ + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 3 } + } + ]) + act(() => root.unmount()) + }) + + it('re-retains when a host is re-added under a new incarnation', () => { + render([sshEntry('t1', 3)]) + render([sshEntry('t1', 4)]) + + expect(retainExternalScopes).toHaveBeenCalledTimes(2) + expect(retainedOwners(1)).toEqual([ + { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 4 } + } + ]) + act(() => root.unmount()) + }) + + it('retains nothing once the page unmounts', () => { + render([entry()]) + act(() => root.unmount()) + + expect(retainedOwners(retainExternalScopes.mock.calls.length - 1)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/automations/use-external-automation-scope-retention.ts b/src/renderer/src/components/automations/use-external-automation-scope-retention.ts new file mode 100644 index 00000000000..294479d8974 --- /dev/null +++ b/src/renderer/src/components/automations/use-external-automation-scope-retention.ts @@ -0,0 +1,32 @@ +import { useEffect, useMemo, useRef } from 'react' +import { ownerKey } from '../../../../shared/automation-owner-key' +import { externalAutomationProbeOwners } from './external-automation-scope-gating' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +/** + * Keeps the main-process probe pool working only on the hosts currently in view. + * + * Probes for a host the user has filtered away are cancelled rather than left to + * finish: they would land after the view moved on, and they compete with the + * Orca traffic the user is waiting on. Leaving the page retains nothing. + */ +export function useExternalAutomationScopeRetention( + entries: readonly AutomationHostCatalogEntry[] +): void { + const owners = useMemo(() => externalAutomationProbeOwners(entries), [entries]) + // Why: the signature gates the send so a re-rendered but identical scope set + // does not cancel and restart probes that are already in flight. + const signature = useMemo(() => owners.map(ownerKey).join(' '), [owners]) + const ownersRef = useRef(owners) + ownersRef.current = owners + + useEffect(() => { + void window.api.automations.retainExternalScopes({ owners: ownersRef.current }) + }, [signature]) + + useEffect(() => { + return () => { + void window.api.automations.retainExternalScopes({ owners: [] }) + } + }, []) +} diff --git a/src/renderer/src/components/automations/use-scoped-external-automations.ts b/src/renderer/src/components/automations/use-scoped-external-automations.ts new file mode 100644 index 00000000000..03a033fca3c --- /dev/null +++ b/src/renderer/src/components/automations/use-scoped-external-automations.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { + ExternalAutomationAction, + ExternalAutomationJob +} from '../../../../shared/automations-types' +import type { ScopedExternalManagerMutationFields } from '../../../../shared/external-automation-scope' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { + createScopedExternalAutomation, + desktopExternalAutomationOwner, + externalAutomationScopes, + listScopedExternalAutomationManagers, + listScopedExternalAutomationRuns, + runScopedExternalAutomationAction, + updateScopedExternalAutomation, + type ExternalAutomationScope, + type ScopedExternalAutomationFailure, + type ScopedExternalAutomationManager +} from './external-automation-scope-client' +import { externalAutomationScopeKey } from './external-automation-scope-keys' +import { useExternalAutomationScopeRetention } from './use-external-automation-scope-retention' + +/** + * External managers for the hosts currently in view, and the scope each one was + * discovered under. + * + * A manager ID is unique only inside one host — `hermes:local` names no + * authority at all — so every call here takes the scope its row was discovered + * under rather than a manager to look one up from. There is no lookup to miss + * and no host to guess at. + */ + +export type ScopedExternalAutomationsInput = { + /** The whole catalog — used to resolve a create target, never to probe. */ + catalogEntries: readonly AutomationHostCatalogEntry[] + /** The hosts in view. Only these are probed, and only these are retained. */ + scopeEntries: readonly AutomationHostCatalogEntry[] +} + +export type ScopedExternalAutomationsView = { + managers: readonly ScopedExternalAutomationManager[] + /** Scopes that answered with neither a manager nor a denial. */ + failures: readonly ScopedExternalAutomationFailure[] + /** Reloads every in-view scope; safe to call from the page's refresh path. */ + reload: () => Promise + /** The scope a new external automation would be created in, or null to disable it. */ + createScope: (connectionId: string | null) => ExternalAutomationScope | null + saveExternalAutomation: ( + scope: ExternalAutomationScope, + fields: ScopedExternalManagerMutationFields, + jobId: string | null + ) => Promise + runExternalAction: ( + scope: ExternalAutomationScope, + jobId: string, + action: ExternalAutomationAction + ) => Promise + fetchRuns: ( + scope: ExternalAutomationScope, + job: ExternalAutomationJob, + page: number, + pageSize: number + ) => Promise<{ runs: readonly ExternalAutomationRunRow[]; totalCount: number }> +} + +type ExternalAutomationRunRow = Awaited< + ReturnType +>['runs'][number] + +const EMPTY_VIEW = { + managers: [] as readonly ScopedExternalAutomationManager[], + failures: [] as readonly ScopedExternalAutomationFailure[] +} + +export function useScopedExternalAutomations({ + catalogEntries, + scopeEntries +}: ScopedExternalAutomationsInput): ScopedExternalAutomationsView { + const [result, setResult] = useState(EMPTY_VIEW) + const catalogEntriesRef = useRef(catalogEntries) + catalogEntriesRef.current = catalogEntries + useExternalAutomationScopeRetention(scopeEntries) + + const scopes = useMemo(() => externalAutomationScopes(scopeEntries), [scopeEntries]) + const signature = useMemo( + () => scopes.map((scope) => externalAutomationScopeKey(scope)).join(' '), + [scopes] + ) + const scopesRef = useRef(scopes) + scopesRef.current = scopes + const signatureRef = useRef(signature) + signatureRef.current = signature + // Seeded with the mount signature: the page's own mount refresh issues the + // first load, and this must not duplicate it. + const loadedSignatureRef = useRef(signature) + // A selection change can overtake the probe it replaced; only the newest wins. + const loadTokenRef = useRef(0) + + const reload = useCallback(async (): Promise => { + const token = ++loadTokenRef.current + loadedSignatureRef.current = signatureRef.current + const next = await listScopedExternalAutomationManagers(scopesRef.current) + if (token !== loadTokenRef.current) { + return + } + setResult({ managers: next.managers, failures: next.failures }) + }, []) + + // The scope set is the query: when it changes, what is on screen is another host's answer. + useEffect(() => { + if (loadedSignatureRef.current === signature) { + return + } + // The old host's managers are not evidence about the new one, so they go now + // rather than when its probe answers. + setResult(EMPTY_VIEW) + void reload().catch(() => undefined) + }, [signature, reload]) + + const createScope = useCallback((connectionId: string | null): ExternalAutomationScope | null => { + const owner = desktopExternalAutomationOwner(catalogEntriesRef.current, connectionId) + // Hermes is the only provider a create dialog offers today; the picker + // that would choose between providers does not exist yet. + return owner ? { owner, provider: 'hermes' } : null + }, []) + + return { + managers: result.managers, + failures: result.failures, + reload, + createScope, + saveExternalAutomation: useCallback( + async (scope, fields, jobId) => + jobId === null + ? await createScopedExternalAutomation(scope, fields) + : await updateScopedExternalAutomation(scope, jobId, fields), + [] + ), + runExternalAction: useCallback( + async (scope, jobId, action) => await runScopedExternalAutomationAction(scope, jobId, action), + [] + ), + fetchRuns: useCallback( + async (scope, job, page, pageSize) => + await listScopedExternalAutomationRuns(scope, job, page, pageSize), + [] + ) + } +} diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx new file mode 100644 index 00000000000..ae5d768293b --- /dev/null +++ b/src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment happy-dom + +/** + * A refused history read is an answer too. It used to be dropped on the floor, + * which left the previous automation's ID in place — so the pane showed zero + * runs for a question the host never answered, and anything waiting on this + * read waited forever. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' +import { ownerKey } from '../../../../shared/automation-owner-key' +import { makeAutomation, makeRun } from './automations-page-fixtures' +import { automationListRowKey, type AutomationListRow } from './automation-list-row-identity' +import { + useSelectedAutomationRunHistory, + type SelectedAutomationRunHistoryInput, + type SelectedAutomationRunHistoryOutcome +} from './use-selected-automation-run-history' + +const mocks = vi.hoisted(() => ({ dispatch: vi.fn() })) + +vi.mock('./automation-row-action-dispatch', () => ({ + dispatchAutomationRunHistory: (...args: unknown[]) => mocks.dispatch(...args) +})) + +const roots: Root[] = [] + +const DESKTOP_ROW: AutomationListRow = { + key: automationListRowKey('host:desktop|self', 'a-1'), + automation: makeAutomation({ id: 'a-1' }), + hostLabel: 'This computer', + usageSummary: null +} + +function Harness(props: { input: SelectedAutomationRunHistoryInput }): null { + useSelectedAutomationRunHistory(props.input) + return null +} + +function makeInput( + overrides: Partial = {} +): SelectedAutomationRunHistoryInput { + return { + selected: DESKTOP_ROW, + context: { capturedOwners: new Map(), authority: { kind: 'desktop' } }, + legacyTarget: () => ({ kind: 'local' }), + navigation: null, + reloadToken: 0, + onSettled: vi.fn(), + ...overrides + } +} + +async function render( + input: SelectedAutomationRunHistoryInput +): Promise<(next: SelectedAutomationRunHistoryInput) => Promise> { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + const rerender = async (next: SelectedAutomationRunHistoryInput): Promise => { + await act(async () => { + root.render() + }) + } + await rerender(input) + return rerender +} + +function settled(input: SelectedAutomationRunHistoryInput): SelectedAutomationRunHistoryOutcome[] { + return (input.onSettled as ReturnType).mock.calls.map( + ([outcome]) => outcome as SelectedAutomationRunHistoryOutcome + ) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) +}) + +describe('useSelectedAutomationRunHistory', () => { + it('reports the refusal instead of leaving the pane on the previous automation', async () => { + mocks.dispatch.mockResolvedValue({ + ok: false, + notice: { message: 'web-01 is not connected', recovery: 'reconnect', severity: 'failure' } + }) + const input = makeInput() + + await render(input) + + expect(settled(input)).toEqual([ + { + automationId: 'a-1', + rowKey: DESKTOP_ROW.key, + ownerKey: 'uncaptured', + runs: [], + notice: { message: 'web-01 is not connected', recovery: 'reconnect', severity: 'failure' } + } + ]) + }) + + it('reports a blocked row rather than discarding its reason', async () => { + mocks.dispatch.mockResolvedValue({ + ok: false, + notice: { + message: 'This automation has no host to run on.', + recovery: null, + severity: 'owner' + } + }) + const input = makeInput() + + await render(input) + + expect(settled(input)[0]?.notice?.severity).toBe('owner') + expect(settled(input)[0]?.automationId).toBe('a-1') + }) + + it('settles a successful read with no notice', async () => { + const runs: AutomationRun[] = [makeRun({ id: 'run-1', automationId: 'a-1' })] + mocks.dispatch.mockResolvedValue({ ok: true, value: runs }) + const input = makeInput() + + await render(input) + + expect(settled(input)).toEqual([ + { automationId: 'a-1', rowKey: DESKTOP_ROW.key, ownerKey: 'uncaptured', runs, notice: null } + ]) + }) + + it('re-asks the host when the reload token changes, so a failure can be retried', async () => { + mocks.dispatch.mockResolvedValue({ + ok: false, + notice: { message: 'offline', recovery: 'retry', severity: 'failure' } + }) + const input = makeInput() + + const rerender = await render(input) + expect(mocks.dispatch).toHaveBeenCalledTimes(1) + + mocks.dispatch.mockResolvedValue({ ok: true, value: [] }) + await rerender({ ...input, reloadToken: 1 }) + + expect(mocks.dispatch).toHaveBeenCalledTimes(2) + expect(settled(input).at(-1)).toEqual({ + automationId: 'a-1', + rowKey: DESKTOP_ROW.key, + ownerKey: 'uncaptured', + runs: [], + notice: null + }) + }) + + it('names the owner the history was read under', async () => { + mocks.dispatch.mockResolvedValue({ ok: true, value: [] }) + const owner: AutomationOwnerRef = { + authority: { kind: 'runtime', environmentId: 'gpu', pairingRevision: 2 }, + selector: { kind: 'self' } + } + const input = makeInput({ + context: { + capturedOwners: new Map([[DESKTOP_ROW.key, { owner, selector: { kind: 'self' } }]]), + authority: { kind: 'desktop' } + } + }) + + await render(input) + + // The ID alone is unique only inside one authority, so the outcome carries + // the owner and the pane can refuse a result read for a different host. + expect(settled(input).at(-1)?.ownerKey).toBe(ownerKey(owner)) + }) + + it('clears the pane when nothing is selected', async () => { + const input = makeInput({ selected: null }) + + await render(input) + + expect(settled(input)).toEqual([ + { automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null } + ]) + expect(mocks.dispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/use-selected-automation-run-history.ts b/src/renderer/src/components/automations/use-selected-automation-run-history.ts new file mode 100644 index 00000000000..c9da1bd7ce4 --- /dev/null +++ b/src/renderer/src/components/automations/use-selected-automation-run-history.ts @@ -0,0 +1,112 @@ +/** + * Fetches only the selected automation's history, through its captured owner. + * + * The refetch policy is a string key rather than the objects the fetch reads: + * the dispatch context is rebuilt on every cache reprojection, and keying an + * effect on it re-asks the same host for the same history in a loop. What + * actually decides the request is the row, the owner it was listed under, and + * the host a navigation named — so those, and only those, are the key. + */ + +import { useEffect, useRef } from 'react' +import type { AutomationRun } from '../../../../shared/automations-types' +import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner' +import type { AutomationListRow } from './automation-list-row-identity' +import { + type AutomationActionNotice, + type AutomationDispatchContext, + dispatchAutomationRunHistory +} from './automation-row-action-dispatch' +import { + getAutomationTargetFromHostId, + listAutomationRunsForTarget, + type AutomationHostTarget +} from './automation-host-client' + +export type SelectedAutomationRunNavigation = { + automationId: string + hostId?: string | null +} | null + +export type SelectedAutomationRunHistoryOutcome = { + automationId: string | null + /** Which listed row was asked; an ID alone cannot name one of two hosts' copies. */ + rowKey: string | null + /** Which incarnation answered; a re-pairing makes the same row's answer stale. */ + ownerKey: string | null + runs: AutomationRun[] + /** + * Non-null when the host refused or failed the read. The runs are then unknown + * rather than absent, and the caller must say so instead of showing zero runs. + */ + notice: AutomationActionNotice | null +} + +export type SelectedAutomationRunHistoryInput = { + selected: AutomationListRow | null + context: AutomationDispatchContext + /** The row's own host, used only when no owner was captured for it. */ + legacyTarget: (row: AutomationListRow) => AutomationHostTarget | null + navigation: SelectedAutomationRunNavigation + /** Changing this re-asks the host; it is how a failed read offers Retry. */ + reloadToken: number + onSettled: (outcome: SelectedAutomationRunHistoryOutcome) => void +} + +function navigationHostId( + navigation: SelectedAutomationRunNavigation, + automationId: string +): string | null { + return navigation?.automationId === automationId ? (navigation.hostId ?? null) : null +} + +export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void { + const inputRef = useRef(input) + inputRef.current = input + + const automationId = input.selected?.automation.id ?? null + const rowKey = input.selected?.key ?? null + const reloadToken = input.reloadToken + const fetchKey = + automationId && rowKey + ? [ + rowKey, + capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)), + navigationHostId(input.navigation, automationId) ?? '' + ].join('|') + : '' + + useEffect(() => { + const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current + if (!selected || !automationId || !rowKey) { + onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null }) + return + } + let cancelled = false + const owner = capturedAutomationOwnerKey( + capturedAutomationOwner(context.capturedOwners, rowKey) + ) + const navigationHost = navigationHostId(navigation, automationId) + const target = navigationHost + ? getAutomationTargetFromHostId(navigationHost) + : (legacyTarget(selected) ?? { kind: 'local' }) + void dispatchAutomationRunHistory(context, { rowKey, automationId }, () => + listAutomationRunsForTarget(target, automationId) + ).then((result) => { + if (cancelled) { + return + } + // A refused or failed read still settles: leaving the previous automation's + // ID in place strands anything waiting on this one and shows its runs as zero. + onSettled( + result.ok + ? { automationId, rowKey, ownerKey: owner, runs: result.value, notice: null } + : { automationId, rowKey, ownerKey: owner, runs: [], notice: result.notice } + ) + }) + return () => { + cancelled = true + } + // oxlint-disable-next-line react-hooks/exhaustive-deps -- fetchKey is the refetch policy; see the module docblock for why the context object is deliberately not a dependency. + }, [automationId, rowKey, fetchKey, reloadToken]) +} diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index 7af24b68947..d8ea235b852 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -293,6 +293,7 @@ describe('RepositoryHostSetupsSection', () => { } ] ]), + targetGenerations: new Map(), targetLabels: new Map([ ['direct', 'Direct box'], ['jump', 'Jump box'] diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx new file mode 100644 index 00000000000..89ca240cc98 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +/** + * The card decides whether to offer "Open automation", which means deciding + * which host to ask. It sits outside `components/automations/`, so a sweep of + * that folder misses it — and it inferred the automation's host from the + * workspace's, which is a different fact. + */ + +import React, { 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 { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' +import type * as AutomationHostClient from '@/components/automations/automation-host-client' +import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' + +const mocks = vi.hoisted(() => ({ + listAutomationsForTarget: vi.fn(), + listAutomationRunsForTarget: vi.fn() +})) + +vi.mock('@/components/automations/automation-host-client', async (importOriginal) => ({ + ...(await importOriginal()), + listAutomationsForTarget: mocks.listAutomationsForTarget, + listAutomationRunsForTarget: mocks.listAutomationRunsForTarget +})) + +function provenance( + overrides: Partial = {} +): AutomationWorkspaceProvenance { + return { + kind: 'created-by-automation', + automationId: 'a-1', + automationNameSnapshot: 'Nightly', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly #1', + createdAt: 1, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1', + ...overrides + } +} + +const roots: Root[] = [] + +async function render( + record: AutomationWorkspaceProvenance, + handlers: { + onOpenAutomation?: (event: React.MouseEvent) => void + onOpenAutomationRun?: (event: React.MouseEvent) => void + } = {} +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + + + ) + }) + return container +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + mocks.listAutomationsForTarget.mockResolvedValue([]) + mocks.listAutomationRunsForTarget.mockResolvedValue([]) +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('WorktreeCardAutomationDetailSection host resolution', () => { + it('asks the host the provenance recorded', async () => { + mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }]) + mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }]) + + await render(provenance({ hostId: 'runtime:gpu' })) + + expect(mocks.listAutomationsForTarget).toHaveBeenCalledWith({ + kind: 'environment', + environmentId: 'gpu' + }) + }) + + it('reports a miss as uncheckable when no host was ever recorded', async () => { + const container = await render(provenance()) + + // The read went to the desktop for want of anywhere else, so its silence is + // not evidence the automation was removed. + expect(container.textContent).toContain('Automation availability could not be checked.') + expect(container.textContent).not.toContain('Automation no longer available.') + }) + + it('still reports a miss as removed when the recorded host answered', async () => { + const container = await render(provenance({ hostId: 'local' })) + + expect(container.textContent).toContain('Automation no longer available.') + }) + + it('offers both affordances when a record with no recorded host is found anyway', async () => { + // The missing host degrades the *miss*, never the find: an automation the + // desktop positively returned is evidence, whatever the read was aimed by. + mocks.listAutomationsForTarget.mockResolvedValue([{ id: 'a-1' }]) + mocks.listAutomationRunsForTarget.mockResolvedValue([{ id: 'run-1' }]) + + const container = await render(provenance(), { + onOpenAutomation: vi.fn(), + onOpenAutomationRun: vi.fn() + }) + + expect(container.querySelector('[aria-label="Open automation"]')).not.toBeNull() + expect(container.querySelector('[aria-label="Open run"]')).not.toBeNull() + expect(container.textContent).not.toContain('could not be checked') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx index 1ba6a45ce48..9f2811a54b9 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx @@ -1,6 +1,5 @@ import React from 'react' import { CalendarClock, PlayCircle } from 'lucide-react' -import type { ExecutionHostId } from '../../../../shared/execution-host' import type { AutomationWorkspaceProvenance } from '../../../../shared/worktree/types' import { WorktreeCardDetailSection, @@ -16,7 +15,6 @@ import { type WorktreeCardAutomationDetailSectionProps = { provenance: AutomationWorkspaceProvenance - worktreeHostId?: ExecutionHostId onOpenAutomation?: (event: React.MouseEvent) => void onOpenAutomationRun?: (event: React.MouseEvent) => void } @@ -29,7 +27,6 @@ type AutomationProvenanceAvailability = export function WorktreeCardAutomationDetailSection({ provenance, - worktreeHostId, onOpenAutomation, onOpenAutomationRun }: WorktreeCardAutomationDetailSectionProps): React.JSX.Element { @@ -42,12 +39,17 @@ export function WorktreeCardAutomationDetailSection({ async function resolveAvailability(): Promise { setAvailability({ status: 'checking' }) try { - const target = getAutomationTargetFromHostId(provenance.hostId ?? worktreeHostId) + // Why not the workspace's host: the automation's host is recorded on the + // provenance, and an automation can land a workspace somewhere else + // entirely — substituting one for the other asks the wrong machine. + const target = getAutomationTargetFromHostId(provenance.hostId) const automations = await listAutomationsForTarget(target) const automation = automations.find((entry) => entry.id === provenance.automationId) if (!automation) { if (!cancelled) { - setAvailability({ status: 'automation-missing' }) + // With no recorded host this read went to the desktop on no evidence, + // so a miss here is "could not check", never "removed". + setAvailability({ status: provenance.hostId ? 'automation-missing' : 'unavailable' }) } return } @@ -69,7 +71,7 @@ export function WorktreeCardAutomationDetailSection({ return () => { cancelled = true } - }, [provenance.automationId, provenance.automationRunId, provenance.hostId, worktreeHostId]) + }, [provenance.automationId, provenance.automationRunId, provenance.hostId]) const canOpenAutomation = availability.status === 'available' const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 4767d7463cb..071d857aed2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -62,7 +62,6 @@ export function WorktreeCardDetailsHover({ workspaceTitle, identityOrder = 'workspace-first', workspaceTitleRenameDisabled = false, - automationHostId, detailsAfter, openDelay = 250, closeDelay = 120, @@ -314,7 +313,6 @@ export function WorktreeCardDetailsHover({ {automationProvenance && ( : null} hoverControl={detailsHoverControl} onEditIssue={affiliateListMode ? undefined : handleEditIssue} diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index 7bc0abdcb89..a9c91c78131 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -82,7 +82,6 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): comment={hoverComment} automationProvenance={metaAutomationProvenance} cliProvenance={metaCliProvenance} - automationHostId={worktree.hostId} branchName={hoverBranchName} workspaceTitle={hoverWorkspaceTitle} workspaceTitleRenameDisabled={isDeleting || affiliateListMode} diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index c94cc4659fc..a27f230538d 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -14,6 +14,20 @@ function SelectValue({ ...props }: React.ComponentProps } +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + function SelectTrigger({ className, size = 'default', @@ -157,7 +171,9 @@ function SelectScrollDownButton({ export { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, diff --git a/src/renderer/src/hooks/automations-changed-event-attribution.test.ts b/src/renderer/src/hooks/automations-changed-event-attribution.test.ts new file mode 100644 index 00000000000..8b9fc9c25f1 --- /dev/null +++ b/src/renderer/src/hooks/automations-changed-event-attribution.test.ts @@ -0,0 +1,153 @@ +/** + * The runtime path used to drop the publisher's identity here, which forced every + * change on any host to invalidate every host. These pin the attribution the hook + * must forward: which environment, and which selector when the host names one. + */ + +import { describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' +import type { AutomationsChangedWindowDetail } from '@/lib/automations-changed-window-event' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' + +type ChangedListener = (payload: AutomationsChangedWindowDetail) => void + +type Mounted = { + /** Call inside the test body: useIpcEvents runs its effects eagerly there. */ + useIpcEvents: () => void + emitRuntimeEvent: (environmentId: string, event: RuntimeClientEvent) => void + emitDesktopChange: (payload: AutomationsChangedWindowDetail) => void + details: () => AutomationsChangedWindowDetail[] +} + +async function mountIpcEvents(): Promise { + vi.resetModules() + vi.unstubAllGlobals() + let onRuntimeEvent: ((environmentId: string, event: RuntimeClientEvent) => void) | null = null + let onDesktopChange: ChangedListener | null = null + const dispatchEvent = vi.fn() + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() } + }) + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => ({ + settings: { activeRuntimeEnvironmentId: null }, + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + tabsByWorktree: {}, + ptyIdsByTabId: {}, + fetchRepos: vi.fn(() => Promise.resolve()), + fetchProjectGroups: vi.fn(() => Promise.resolve()), + fetchFolderWorkspaces: vi.fn(() => Promise.resolve()) + }) + } + })) + vi.doMock('./runtime-client-events-sync', () => ({ + createRuntimeClientEventsSync: (deps: { + onEvent: (environmentId: string, event: RuntimeClientEvent) => void + }) => { + onRuntimeEvent = deps.onEvent + return { sync: vi.fn(), stop: vi.fn() } + } + })) + + // Why: the hook registers dozens of IPC namespaces at mount; stub the rest so a + // pending promise keeps unrelated paths dormant instead of failing on store setters. + const autoStubNamespace = new Proxy( + {}, + { + get: + () => + (...args: unknown[]) => + typeof args[0] === 'function' ? () => {} : new Promise(() => {}) + } + ) + const api = new Proxy( + { + automations: new Proxy( + { + onChanged: (listener: ChangedListener) => { + onDesktopChange = listener + return () => {} + } + } as Record, + { get: (target, prop: string) => target[prop] ?? autoStubNamespace[prop] } + ) + } as Record, + { get: (target, prop: string) => target[prop] ?? autoStubNamespace } + ) + vi.stubGlobal('window', { + api, + dispatchEvent, + addEventListener: () => {}, + removeEventListener: () => {} + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + return { + useIpcEvents, + emitRuntimeEvent: (environmentId, event) => { + if (!onRuntimeEvent) { + throw new Error('Expected the runtime client-event handler to be registered') + } + onRuntimeEvent(environmentId, event) + }, + emitDesktopChange: (payload) => { + if (!onDesktopChange) { + throw new Error('Expected the desktop automations:changed listener to be registered') + } + onDesktopChange(payload) + }, + details: () => + dispatchEvent.mock.calls + .map(([event]) => event as CustomEvent) + .filter((event) => event.type === AUTOMATIONS_CHANGED_EVENT) + .map((event) => event.detail) + } +} + +describe('automationsChanged attribution through useIpcEvents', () => { + it('carries the environment and selector of a runtime-published change', async () => { + const { useIpcEvents, emitRuntimeEvent, details } = await mountIpcEvents() + useIpcEvents() + + emitRuntimeEvent('env-1', { + type: 'automationsChanged', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + }) + + expect(details()).toEqual([ + { + environmentId: 'env-1', + selector: { kind: 'ssh', targetId: 'target-1' }, + reason: 'definition' + } + ]) + }) + + it('still names the environment when an older host sends no selector', async () => { + const { useIpcEvents, emitRuntimeEvent, details } = await mountIpcEvents() + useIpcEvents() + + emitRuntimeEvent('env-2', { type: 'automationsChanged' }) + + expect(details()).toEqual([{ environmentId: 'env-2' }]) + }) + + it('forwards the desktop payload unchanged, so it stays the desktop authority', async () => { + const { useIpcEvents, emitDesktopChange, details } = await mountIpcEvents() + useIpcEvents() + + emitDesktopChange({ selector: { kind: 'self' }, reason: 'definition' }) + + expect(details()).toEqual([{ selector: { kind: 'self' }, reason: 'definition' }]) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx index 75c26fe627c..300c07a9280 100644 --- a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx +++ b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx @@ -142,6 +142,7 @@ describe('runtime and direct SSH wake isolation', () => { } ] ]), + targetGenerations: new Map(), targetLabels: new Map([['runtime-owned-target', 'runtime target']]), removedTargetLabels: new Map(), targetsHydrated: true diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index 089ab323a85..e6edda407fc 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -23,6 +23,7 @@ export function buildWindowApi(args: { return { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 557663a8a25..7b2646eaf9e 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -1,5 +1,8 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' + +const mockDispatchEvent = vi.fn() const mockLaunchAgentBackgroundSession = vi.fn() const mockLaunchWorktreeBackgroundTerminals = vi.fn() @@ -225,10 +228,21 @@ describe('useAutomationDispatchEvents setup launch', () => { connect: mockSshConnect } }, - dispatchEvent: vi.fn() + dispatchEvent: mockDispatchEvent }) }) + // The scoped event main publishes with the write is the only one; a local emit + // here would name no host and re-invalidate every host in the catalog. + it('leaves the automationsChanged event to the host that owns the write', async () => { + await registerAndDispatch() + + expect(mockMarkDispatchResult).toHaveBeenCalled() + expect( + mockDispatchEvent.mock.calls.filter(([event]) => event?.type === AUTOMATIONS_CHANGED_EVENT) + ).toEqual([]) + }) + it('starts setup terminal launch without waiting before launching the automation agent', async () => { const order: string[] = [] let finishSetupLaunch: (() => void) | null = null diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index a7741ec959e..ee5cfe4dcfb 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -31,10 +31,9 @@ import { toSshExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types' +import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host' -const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() function acquireReuseDispatchTab(tabId: string): (() => void) | null { @@ -91,8 +90,10 @@ export function useAutomationDispatchEvents(): void { const unsubscribe = window.api.automations.onDispatchRequested( async ({ automation, run, dispatchToken }) => { const markDispatchResult = async (result: AutomationDispatchResult): Promise => { + // Deliberately no local emit: the write publishes its own host-scoped event + // (automation-run-writer.ts) before this reply returns, and an unscoped one + // here would arrive second and invalidate every host in the catalog. await window.api.automations.markDispatchResult(result) - window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) } const state = useAppStore.getState() const focusBeforeDispatch = { @@ -140,17 +141,22 @@ export function useAutomationDispatchEvents(): void { } try { - const folderWorkspaceConnectionId = + const folderWorkspaceHost = automationWorkspaceScope?.type === 'folder' - ? getFolderWorkspaceConnectionId(state, automationWorkspaceScope.folderWorkspaceId) + ? resolveFolderWorkspaceHost(state, automationWorkspaceScope.folderWorkspaceId) : null + const folderWorkspaceConnectionId = + folderWorkspaceHost?.kind === 'ssh' ? folderWorkspaceHost.targetId : null + // A workspace whose host does not resolve to one place is refused, not guessed at. + const folderWorkspaceHostUnresolved = + folderWorkspaceHost !== null && folderWorkspaceHost.kind === 'ambiguous' const folderWorkspaceHostId = - automationWorkspaceScope?.type === 'folder' && automationWorktree - ? folderWorkspaceConnectionId === undefined - ? null - : folderWorkspaceConnectionId - ? toSshExecutionHostId(folderWorkspaceConnectionId) - : getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + folderWorkspaceHost && automationWorktree + ? folderWorkspaceConnectionId + ? toSshExecutionHostId(folderWorkspaceConnectionId) + : folderWorkspaceHost.kind === 'local' + ? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id) + : null : null const runHostId = parseExecutionHostId(automation.runContext?.hostId)?.id ?? getRepoExecutionHostId(repo) @@ -169,10 +175,15 @@ export function useAutomationDispatchEvents(): void { status: 'skipped_unavailable', workspaceId: automation.workspaceId, workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', - 'The target workspace is on a different host than this automation run target.' - ) + error: folderWorkspaceHostUnresolved + ? translate( + 'auto.hooks.useAutomationDispatchEvents.workspaceHostUnresolved', + 'The target workspace spans more than one host, so this run has no single host to use.' + ) + : translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) }) return } diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index abfbb4d0909..8c733e00f91 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -136,6 +136,7 @@ describe('useIpcEvents browser tab create routing', () => { clearTimeout: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onGitStatusMetadataChanged: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index f84d6a6c6eb..4d5da0102e9 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -108,6 +108,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -376,6 +377,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: (callback: (data: { repoId: string }) => void) => { localWorktreesOnChanged = callback diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts index 48378b48623..4b6049ef272 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts @@ -114,6 +114,7 @@ describe('useIpcEvents browser tab close routing', () => { dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -337,6 +338,7 @@ describe('useIpcEvents browser tab close routing', () => { dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -555,6 +557,7 @@ describe('useIpcEvents browser tab close routing', () => { dispatchEvent: vi.fn(), api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts index a670cb2949f..3cfef78b1d0 100644 --- a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts @@ -148,6 +148,7 @@ describe('useIpcEvents rate-limit hydration', () => { clearTimeout: vi.fn(), api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 6cac4121fee..2f2cc1793c8 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -124,6 +124,7 @@ describe('useIpcEvents updater integration', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts index 97e2ae54dcb..ec1bfe5ca64 100644 --- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts @@ -89,6 +89,7 @@ describe('useIpcEvents updater integration', () => { vi.stubGlobal('window', { api: { repos: { onChanged: () => () => {} }, + automations: { onChanged: () => () => {} }, worktrees: { onChanged: () => () => {}, onBaseStatus: () => () => {}, @@ -287,6 +288,7 @@ describe('useIpcEvents updater integration', () => { vi.stubGlobal('window', { api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), diff --git a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts index 9a9ec54974f..ad1225d2fb9 100644 --- a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts @@ -186,6 +186,7 @@ describe('useIpcEvents zoom routing', () => { clearTimeout: vi.fn(), api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), @@ -329,6 +330,7 @@ describe('useIpcEvents zoom routing', () => { clearTimeout: vi.fn(), api: { repos: makeEvents(), + automations: makeEvents(), worktrees: makeEvents(), keybindings: makeEvents(), settings: makeEvents(), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 333f5b51a69..f5b52d062ac 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -200,6 +200,7 @@ import { type DirectSshConnectedStateOrigin } from './direct-ssh-state-routing' import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout' +import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' function getShortcutPlatform(): NodeJS.Platform { if (navigator.userAgent.includes('Mac')) { @@ -1026,6 +1027,16 @@ export function useIpcEvents(): void { runtimeProjectRefreshScheduler.request(environmentId) return } + if (event.type === 'automationsChanged') { + // Why: without the environment the subscriber cannot tell which authority + // changed, and one host's edit would refetch every host in the catalog. + emitAutomationsChangedWindowEvent({ + environmentId, + ...(event.selector ? { selector: event.selector } : {}), + ...(event.reason ? { reason: event.reason } : {}) + }) + return + } if (event.type === 'sshStateChanged') { applyRuntimeEnvironmentSshStateChanged( environmentId, @@ -1136,6 +1147,11 @@ export function useIpcEvents(): void { reachableRuntimeEnvironmentKey = nextReachableKey runtimeClientEventsSync.sync() }) + // Why: the desktop authority publishes automation writes on its own channel; + // runtime authorities publish the same event over the client-event stream. + unsubs.push( + window.api.automations.onChanged((payload) => emitAutomationsChangedWindowEvent(payload)) + ) unsubs.push(runtimeClientEventsSync.stop) unsubs.push(runtimeProjectRefreshScheduler.stop) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7f559834060..025e1b0f22d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -826,7 +826,8 @@ "59718b120b": "The target workspace is no longer available.", "16a21d6413": "SSH reconnect requires interactive credentials.", "386db94f3e": "The target project is no longer available.", - "3ad7d77f57": "The target workspace is on a different host than this automation run target." + "3ad7d77f57": "The target workspace is on a different host than this automation run target.", + "workspaceHostUnresolved": "The target workspace spans more than one host, so this run has no single host to use." }, "useComposerState": { "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", @@ -15102,7 +15103,8 @@ "8faaa00726": "Run", "53fc5f07ab": "Run history", "a00e38d1a3": "n/a", - "fdb3caa8fb": "known" + "fdb3caa8fb": "known", + "historyUnavailable": "Run history could not be loaded for this automation." }, "AutomationRunPageFrame": { "40a511bed4": "Run context", @@ -15385,6 +15387,115 @@ "AutomationListSortHeader": { "sortedAscending": "{{value0}}, sorted ascending", "sortedDescending": "{{value0}}, sorted descending" + }, + "hostSummary": { + "partialFailure": "{{failed}} of {{total}} hosts could not be loaded" + }, + "emptyState": { + "unknownHost": "this host", + "externalManagersOutOfScope": "External automation managers are not listed for this host in this release.", + "hostLoading": "Loading host…", + "hostLoadingDetail": "Waiting for {{hostLabel}} to report its automations.", + "hostError": "Automations could not be loaded from {{hostLabel}}", + "hostUnavailableDetail": "The host could not be reached, so its automations are unknown.", + "hostIncompatibleDetail": "This server is too old to scope automations by host.", + "hostStaleDetail": "The most recent refresh did not complete.", + "hostNotConnected": "{{hostLabel}} is not connected", + "hostNotConnectedDetail": "Connect to this host to see the automations stored for it.", + "hostEmpty": "No automations on {{hostLabel}}", + "searchNoMatch": "No automations match your search", + "allHostsEmpty": "No automations across loaded hosts" + }, + "hostNotice": { + "loading": "Loading host…", + "unavailable": "{{hostLabel}} could not be reached. Showing the automations last loaded from it.", + "ghost": "{{hostLabel}} was removed. Automations still assigned to it remain listed.", + "removed": "The selected host is no longer available. Showing all hosts." + }, + "hostPicker": { + "allHosts": "All hosts", + "loadingHost": "Loading host…", + "label": "Filter by host", + "searchPlaceholder": "Search hosts", + "noHostMatch": "No hosts match" + }, + "ownerConflict": { + "dismiss": "Dismiss" + }, + "capturedOwner": { + "orphan": "This automation has no host to run on. Delete it, or re-add the host it belongs to.", + "unfenced": "This host cannot confirm which automation it is changing, so this automation is read-only here." + }, + "hostStatus": { + "authority": { + "loading": "Loading", + "loadingDescription": "Loading automations from this host.", + "fresh": "Up to date", + "freshDescription": "Automations loaded from this host.", + "refreshing": "Refreshing", + "refreshingDescription": "Checking this host for changes.", + "staleError": "Not refreshed", + "staleErrorDescription": "Showing the automations last loaded from this host. The most recent refresh did not complete.", + "unavailable": "Unreachable", + "unavailableDescription": "This host could not be reached, so its automations could not be loaded.", + "incompatible": "Update server", + "incompatibleDescription": "This server does not support host-scoped automation queries. Update it to load automations for this host." + }, + "execution": { + "connected": "Connected", + "connectedDescription": "This host is connected and can run automations.", + "connecting": "Connecting", + "connectingDescription": "Connecting to this host.", + "disconnected": "Not connected", + "disconnectedDescription": "Automations on this host cannot run until the connection is restored.", + "unavailable": "Unavailable", + "unavailableDescription": "This execution target is no longer available.", + "unknown": "Connection unknown", + "unknownDescription": "The connection state for this host has not loaded yet." + }, + "query": { + "legacyUnscopedDescription": "This server lists automations without host scoping, so they cannot be edited or run from here.", + "incompatible": "Update server", + "incompatibleDescription": "This server is too old to scope automations by host. Update it to manage automations here.", + "viewOnly": "View only", + "targetRemovedDescription": "This host was removed, so its automations can no longer be edited or run from here.", + "targetUnverifiedDescription": "This host has not been verified since its connection dropped, so its automations cannot be edited or run from here.", + "targetUnregisteredDescription": "This host has no registration record, so its automations cannot be edited or run from here." + }, + "action": { + "retry": "Retry", + "reconnect": "Reconnect", + "updateServer": "Update server" + } + }, + "ownerAction": { + "failed": "That automation action did not complete." + }, + "externalScope": { + "targetHidden": "This host is managed by Orca, so its external automation managers are not listed in this release.", + "unknownManager": "This external automation is no longer listed for any host in view.", + "uncheckedHost": "External automation managers on {{hostLabel}} could not be checked.", + "uncheckedHosts": "External automation managers on {{count}} hosts could not be checked." + }, + "enablement": { + "pausedByOrca": "Paused by Orca", + "paused": "Paused", + "hostChangedReason": "Orca paused this automation because it could not tell which host it belongs to. You did not pause it.", + "hostChangedRecovery": "Re-add that host to restore it, resume the automation to run it anyway, or delete it if you no longer need it." + }, + "runHistory": { + "occurrencesWithLatest": "{{times}} times, most recently {{date}}", + "occurrences": "{{times}} times" + }, + "createDestination": { + "label": "Create on", + "placeholder": "Select a host", + "storedOn": "Stored and scheduled by {authority}.", + "unselected": "Choose the host this automation will be created on.", + "orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.", + "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", + "stale": "{host} changed while this form was open. Choose it again before saving.", + "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host." } }, "agent": { diff --git a/src/renderer/src/lib/automations-changed-window-event.ts b/src/renderer/src/lib/automations-changed-window-event.ts new file mode 100644 index 00000000000..3e7be91b13b --- /dev/null +++ b/src/renderer/src/lib/automations-changed-window-event.ts @@ -0,0 +1,24 @@ +/** Renderer-internal fan-out for authority `automationsChanged` events, so the + * Automations page refreshes without every publisher knowing about the page. */ +import type { AutomationsChangedPayload } from '../../../shared/runtime-client-events' + +export const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' + +/** `environmentId` names the runtime authority that published; absent means desktop. */ +export type AutomationsChangedWindowDetail = AutomationsChangedPayload & { + environmentId?: string | null +} + +export function emitAutomationsChangedWindowEvent( + detail: AutomationsChangedWindowDetail = {} +): void { + window.dispatchEvent( + new CustomEvent(AUTOMATIONS_CHANGED_EVENT, { detail }) + ) +} + +/** Missing or malformed detail degrades to an unscoped desktop event, never a dropped one. */ +export function automationsChangedWindowDetail(event: Event): AutomationsChangedWindowDetail { + const detail = (event as CustomEvent).detail + return detail && typeof detail === 'object' ? (detail as AutomationsChangedWindowDetail) : {} +} diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index f319e6a84ea..43e257419db 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -140,6 +140,7 @@ describe('editor file operation owner', () => { 'hub-a', { targetsHydrated: true, + targetGenerations: new Map(), targetLabels: new Map([['private', 'private']]), removedTargetLabels: new Map(), connectionStates: new Map([ diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index 7edfbeedf32..b0840fc7e9f 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -1,112 +1,27 @@ -import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { + findFolderWorkspaceCandidateRepos, + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from '../../../shared/folder-workspace-execution-host' -export type FolderWorkspaceConnectionState = { - folderWorkspaces: readonly FolderWorkspace[] - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] -} - -function getFolderScopeCandidateRepos(args: { - folderPath: string - projectGroupId: string - connectionId?: string | null - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] -}): Repo[] { - const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) - const groupRepos = args.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = args.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(args.folderPath, repo.path) - ) - if (args.connectionId) { - return [ - ...groupRepos, - ...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId) - ] - } - if (groupRepos.length === 0) { - return pathRepos - } - const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) - return [ - ...groupRepos, - ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) - ] -} +export type FolderWorkspaceConnectionState = FolderWorkspaceHostState export function getFolderWorkspaceCandidateRepos( state: FolderWorkspaceConnectionState, folderWorkspaceId: string ): Repo[] { - const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) - if (!workspace) { - return [] - } - const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) - return getFolderScopeCandidateRepos({ - folderPath: workspace.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: workspace.connectionId ?? group?.connectionId ?? null, - projectGroups: state.projectGroups, - repos: state.repos - }) + return findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) } +/** Legacy tri-state view of the shared resolution: `undefined` = gone or ambiguous. */ export function getFolderWorkspaceConnectionId( state: FolderWorkspaceConnectionState, folderWorkspaceId: string ): string | null | undefined { - const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) - if (!workspace) { - return undefined + const host = resolveFolderWorkspaceHost(state, folderWorkspaceId) + if (host.kind === 'ssh') { + return host.targetId } - const explicitHost = parseExecutionHostId(workspace.executionHostId) - if (explicitHost) { - return explicitHost.kind === 'ssh' ? explicitHost.targetId : null - } - const scopeConnectionId = - workspace.connectionId ?? - state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null - const candidateRepos = getFolderWorkspaceCandidateRepos(state, folderWorkspaceId) - let hasLocalRepo = false - const connectionIds = new Set() - for (const repo of candidateRepos) { - if (repo.connectionId) { - connectionIds.add(repo.connectionId) - } else { - hasLocalRepo = true - } - } - if (scopeConnectionId) { - const hasDifferentSshConnection = [...connectionIds].some( - (connectionId) => connectionId !== scopeConnectionId - ) - if (hasLocalRepo || hasDifferentSshConnection) { - return undefined - } - return scopeConnectionId - } - if (candidateRepos.length === 0) { - return null - } - if (hasLocalRepo && connectionIds.size > 0) { - return undefined - } - if (connectionIds.size === 0) { - return null - } - if (connectionIds.size === 1) { - return [...connectionIds][0] - } - return undefined + return host.kind === 'local' ? null : undefined } diff --git a/src/renderer/src/lib/ssh-mutation-expectation.test.ts b/src/renderer/src/lib/ssh-mutation-expectation.test.ts index 97055f14475..9e7036cba8a 100644 --- a/src/renderer/src/lib/ssh-mutation-expectation.test.ts +++ b/src/renderer/src/lib/ssh-mutation-expectation.test.ts @@ -33,6 +33,7 @@ function stateWithGenerations(): Pick { expect(unsubscribe).toHaveBeenCalledTimes(1) }) + it('forwards automationsChanged and still drops event types it does not know', async () => { + let capturedOnResponse: ((response: unknown) => void) | undefined + const subscribe = vi.fn(async (_args, nextCallbacks) => { + capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + }) + const onEvent = vi.fn() + const onError = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } }) + + await subscribeRuntimeClientEvents('env-1', onEvent, onError) + if (!capturedOnResponse) { + throw new Error('Expected subscription callbacks') + } + + capturedOnResponse({ ok: true, result: { type: 'automationsChanged', reason: 'run' } }) + // Why: the same allowlist is what makes this event additive for old clients. + capturedOnResponse({ ok: true, result: { type: 'automationsFromTheFuture' } }) + + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith({ type: 'automationsChanged', reason: 'run' }) + expect(onError).not.toHaveBeenCalled() + }) + it('signals a replay-tagged response so event-derived state can resync after a reconnect', async () => { let capturedOnResponse: ((response: unknown) => void) | undefined const subscribe = vi.fn(async (_args, nextCallbacks) => { diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 2631166379c..9e438e68e6a 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -89,6 +89,7 @@ function isRuntimeClientEvent( message.type === 'nativeChatLaunchDraftResolved' || message.type === 'terminalSideEffects' || message.type === 'sshStateChanged' || + message.type === 'automationsChanged' || message.type === 'linearLinkedIssueUpdated' || message.type === 'activateWorktree' || message.type === 'worktreeTerminalSleepState' diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts index 333720a61a0..59f1a3256ae 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -3,7 +3,12 @@ import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-t import { callRuntimeRpc } from './runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' -import { sshTargetLabelsEqual } from '@/store/slices/ssh-target-cleanup' +import { + collectSshTargetGenerations, + sshTargetGenerationsEqual, + sshTargetLabelsEqual +} from '@/store/slices/ssh-target-cleanup' +import { sanitizeSshTargetGeneration } from '../../../shared/ssh-target-generation' /** * Mirrors a remote Orca server's own SSH targets into that environment's @@ -33,7 +38,13 @@ async function fetchEnvironmentSshTargets(environmentId: string): Promise() + const nextTargetGenerations = collectSshTargetGenerations(targets) const metadataChanged = - !bucket?.targetsHydrated || !sshTargetLabelsEqual(bucket.targetLabels, targets) + !bucket?.targetsHydrated || + !sshTargetLabelsEqual(bucket.targetLabels, targets) || + !sshTargetGenerationsEqual(priorTargetGenerations, nextTargetGenerations) useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets, generation) const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? []) // Why: read states after the write — a resync racing this fetch can label a target that never had one read. @@ -136,7 +151,10 @@ async function runEnvironmentSshTargetMetadataRefresh(environmentId: string): Pr .getState() .sshStateByEnvironment.get(environmentId)?.connectionStates const needStateRead = targets.filter( - (target) => !priorTargetIds.has(target.id) || !knownStates?.has(target.id) + (target) => + !priorTargetIds.has(target.id) || + !knownStates?.has(target.id) || + priorTargetGenerations.get(target.id) !== nextTargetGenerations.get(target.id) ) if (!metadataChanged) { await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation) diff --git a/src/renderer/src/runtime/runtime-rpc-result.ts b/src/renderer/src/runtime/runtime-rpc-result.ts index c168b3ab116..a0606cf76b6 100644 --- a/src/renderer/src/runtime/runtime-rpc-result.ts +++ b/src/renderer/src/runtime/runtime-rpc-result.ts @@ -1,5 +1,8 @@ import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +// Why: the CLI reads the same flattened tokens, so the matcher lives in shared and both clients use one copy. +export { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' + export class RuntimeRpcCallError extends Error { readonly code: string readonly response: RuntimeRpcFailure @@ -12,55 +15,6 @@ export class RuntimeRpcCallError extends Error { } } -// Why: transports re-wrap the token into a longer message and drop the cause (Electron IPC's -// "Error invoking remote method 'x': Error: selector_not_found", relay envelope re-throws), so the -// token classifies only as the whole message or after a real message boundary (": " or a newline) — -// prose that merely trails off in the token must not reach the destructive forget-local fallback. -const CODE_TOKEN_BOUNDARY = /(?:: |\n)[ \t]*$/ - -function endsWithCodeToken(text: string, expectedCode: string): boolean { - const trimmed = text.trimEnd() - if (!trimmed.endsWith(expectedCode)) { - return false - } - const prefix = trimmed.slice(0, -expectedCode.length) - return prefix.trim() === '' || CODE_TOKEN_BOUNDARY.test(prefix) -} - -export function hasRuntimeRpcErrorCode(error: unknown, expectedCode: string): boolean { - const seen = new Set() - let current = error - while (!seen.has(current)) { - if (typeof current === 'string') { - return endsWithCodeToken(current, expectedCode) - } - if (!current || typeof current !== 'object') { - return false - } - seen.add(current) - const candidate = current as { - cause?: unknown - code?: unknown - message?: unknown - response?: { error?: { code?: unknown; message?: unknown } } - } - // Machine tokens are checked before messages so a subclass with a human-readable message still classifies by code. - if (candidate.code === expectedCode || candidate.response?.error?.code === expectedCode) { - return true - } - const messages = [candidate.message, candidate.response?.error?.message] - if ( - messages.some( - (message) => typeof message === 'string' && endsWithCodeToken(message, expectedCode) - ) - ) { - return true - } - current = candidate.cause - } - return false -} - export function unwrapRuntimeRpcResult(response: RuntimeRpcResponse): TResult { if (response.ok === false) { throw new RuntimeRpcCallError(response) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts index acc807073cd..0cc4f0ab3f4 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts @@ -251,3 +251,83 @@ describe('runtime-environment-ssh slice', () => { }) }) }) + +describe('runtime-environment-ssh target registration generations', () => { + it('mirrors generations per target and leaves omitted ones absent', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [ + { id: 'ssh-a', label: 'a-box', generation: 4 }, + { id: 'ssh-legacy', label: 'legacy-box' } + ]) + const bucket = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucket?.targetGenerations.get('ssh-a')).toBe(4) + expect(bucket?.targetGenerations.has('ssh-legacy')).toBe(false) + }) + + it('rejects generation values that could not have been issued', () => { + const store = createTestStore() + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [ + { id: 'zero', label: 'z', generation: 0 }, + { id: 'fraction', label: 'f', generation: 1.5 }, + { id: 'negative', label: 'n', generation: -3 } + ]) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.targetGenerations.size).toBe(0) + }) + + it('updates the bucket when only a generation changed', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + const before = store.getState().sshStateByEnvironment.get(ENV_A) + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 5 }]) + const after = store.getState().sshStateByEnvironment.get(ENV_A) + expect(after).not.toBe(before) + expect(after?.targetGenerations.get('ssh-a')).toBe(5) + }) + + it('keeps the bucket reference when labels and generations are unchanged', () => { + const store = createTestStore() + const targets = [{ id: 'ssh-a', label: 'a-box', generation: 4 }] + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, targets) + const before = store.getState().sshStateByEnvironment.get(ENV_A) + store.getState().setEnvironmentSshTargetsMetadata(ENV_A, [...targets]) + expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(before) + }) + + it('clears generations when the environment SSH state goes stale, keeping labels', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + store.getState().markEnvironmentSshStateStale(ENV_A) + const bucket = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucket?.targetGenerations.size).toBe(0) + expect(bucket?.targetLabels.get('ssh-a')).toBe('a-box') + expect(bucket?.targetsHydrated).toBe(false) + }) + + it('drops generations with the whole bucket on environment removal', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + store.getState().removeEnvironmentSshState(ENV_A) + expect(store.getState().sshStateByEnvironment.has(ENV_A)).toBe(false) + }) + + it('respects the stale-generation guard', () => { + const store = createTestStore() + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 4 }]) + store.getState().markEnvironmentSshStateStale(ENV_A) + // Generation 0 belongs to the pre-stale incarnation and must be ignored. + store + .getState() + .setEnvironmentSshTargetsMetadata(ENV_A, [{ id: 'ssh-a', label: 'a-box', generation: 9 }], 0) + expect(store.getState().sshStateByEnvironment.get(ENV_A)?.targetGenerations.size).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts index eb8880e677e..f8fc1762c42 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -5,6 +5,7 @@ import type { SshConnectionStatus, SshTargetSummary } from '../../../../shared/ssh-types' +import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' /** @@ -16,6 +17,11 @@ import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cle export type RuntimeEnvironmentSshBucket = { connectionStates: Map targetLabels: Map + /** Durable SSH *registration* generation per target, for automation owner + * fencing. Absent for targets an older server omitted it for — never + * defaulted, because a guessed generation would fence against the wrong + * registration. Cleared with the rest of the bucket when state goes stale. */ + targetGenerations: Map removedTargetLabels: Map /** Mirrors the local `sshTargetsHydrated` positive-evidence rule: absence * from `targetLabels` only counts as target removal once a target list @@ -60,10 +66,29 @@ export type RuntimeEnvironmentSshSlice = { const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = { connectionStates: new Map(), targetLabels: new Map(), + targetGenerations: new Map(), removedTargetLabels: new Map(), targetsHydrated: false } +function collectTargetGenerations(targets: SshTargetSummary[]): Map { + const generations = new Map() + for (const target of targets) { + const generation = sanitizeSshTargetGeneration(target.generation) + if (generation !== undefined) { + generations.set(target.id, generation) + } + } + return generations +} + +function targetGenerationsEqual(current: Map, next: Map): boolean { + return ( + current.size === next.size && + [...next].every(([targetId, generation]) => current.get(targetId) === generation) + ) +} + const stateGenerationByEnvironment = new Map() const targetConnectionGenerationByEnvironment = new Map() @@ -173,7 +198,11 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< const connectionStates = new Map( Array.from(bucket.connectionStates).filter(([targetId]) => targetIds.has(targetId)) ) - if (sshTargetLabelsEqual(bucket.targetLabels, targets)) { + const targetGenerations = collectTargetGenerations(targets) + if ( + sshTargetLabelsEqual(bucket.targetLabels, targets) && + targetGenerationsEqual(bucket.targetGenerations, targetGenerations) + ) { // Why: an unchanged (even empty) list is still a successful load — the // hydration flag must flip on the first fetch of an empty target set. return bucket.targetsHydrated @@ -184,6 +213,7 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< ...bucket, connectionStates, targetLabels: new Map(targets.map((target) => [target.id, target.label])), + targetGenerations, targetsHydrated: true }) }), @@ -207,14 +237,21 @@ export const createRuntimeEnvironmentSshSlice: StateCreator< set((s) => { advanceEnvironmentSshStateGeneration(environmentId) const bucket = s.sshStateByEnvironment.get(environmentId) - if (!bucket || (!bucket.targetsHydrated && bucket.connectionStates.size === 0)) { + if ( + !bucket || + (!bucket.targetsHydrated && + bucket.connectionStates.size === 0 && + bucket.targetGenerations.size === 0) + ) { return s } // Labels are kept so a re-hydrating overlay can still show a friendly // host name; hydration=false alone forces reads back to "unknown". + // Generations are dropped: fencing must never run on unverified state. return withBucket(s, environmentId, { ...bucket, connectionStates: new Map(), + targetGenerations: new Map(), targetsHydrated: false }) }), diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 43707fa86d6..5750894936b 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -1,6 +1,7 @@ import type { AppState } from '../types' -import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import type { SshConnectionState, SshTarget, SshTargetSummary } from '../../../../shared/ssh-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope' export function sshConnectionStatesEqual( @@ -29,6 +30,34 @@ export function sshTargetLabelsEqual( return targets.every((target) => labels.get(target.id) === target.label) } +/** + * Registration generations by target ID, dropping any the sanitizer rejects. + * + * An absent generation is left absent rather than defaulted: only a generation + * makes a target fenceable, and a guessed one would fence an automation against + * a registration that never existed. + */ +export function collectSshTargetGenerations(targets: SshTargetSummary[]): Map { + const generations = new Map() + for (const target of targets) { + const generation = sanitizeSshTargetGeneration(target.generation) + if (generation !== undefined) { + generations.set(target.id, generation) + } + } + return generations +} + +export function sshTargetGenerationsEqual( + current: Map, + next: Map +): boolean { + return ( + current.size === next.size && + [...next].every(([targetId, generation]) => current.get(targetId) === generation) + ) +} + function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set { const targetWorktreeIds = resolveDirectSshTargetScope({ targetId, @@ -206,6 +235,10 @@ export function buildRemovedSshTargetCleanupPatch( const removedConnectionState = nextConnectionStates.delete(targetId) const nextLabels = new Map(state.sshTargetLabels) const removedLabel = nextLabels.delete(targetId) + // Why: a lingering generation would keep a deleted registration fenceable, and + // the id is reissued fresh on re-add, so the old value can never become right. + const nextGenerations = new Map(state.sshTargetGenerations) + const removedGeneration = nextGenerations.delete(targetId) const nextHydrated = new Set(state.remoteWorkspaceHydratedTargetIds) const removedHydrated = nextHydrated.delete(targetId) const removedSyncStatus = Object.hasOwn(state.remoteWorkspaceSyncStatusByTargetId, targetId) @@ -225,6 +258,7 @@ export function buildRemovedSshTargetCleanupPatch( removedTransientClearBlock || removedConnectionState || removedLabel || + removedGeneration || removedHydrated || removedSyncStatus || removedPortForwards || @@ -247,6 +281,7 @@ export function buildRemovedSshTargetCleanupPatch( : {}), ...(removedConnectionState ? { sshConnectionStates: nextConnectionStates } : {}), ...(removedLabel ? { sshTargetLabels: nextLabels } : {}), + ...(removedGeneration ? { sshTargetGenerations: nextGenerations } : {}), ...(removedHydrated ? { remoteWorkspaceHydratedTargetIds: nextHydrated } : {}), ...(removedSyncStatus ? { remoteWorkspaceSyncStatusByTargetId: nextSyncStatus } : {}), ...(removedPortForwards ? { portForwardsByConnection: nextPortForwards } : {}), diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index 00ef5532140..6a24ba58954 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -330,6 +330,88 @@ describe('createSshSlice', () => { expect(store.getState().sshTargetLabels).toBe(labels) }) + it('mirrors registration generations so desktop SSH hosts are fenceable', () => { + const store = createTestStore() + + store.getState().setSshTargetsMetadata([ + { id: 'ssh-1', label: 'Remote', generation: 4 }, + { id: 'ssh-2', label: 'Other', generation: 7 } + ]) + + expect(store.getState().sshTargetGenerations.get('ssh-1')).toBe(4) + expect(store.getState().sshTargetGenerations.get('ssh-2')).toBe(7) + }) + + it('leaves an unusable generation absent rather than defaulting it', () => { + const store = createTestStore() + + store.getState().setSshTargetsMetadata([ + { id: 'ssh-legacy', label: 'Legacy' }, + { id: 'ssh-bad', label: 'Bad', generation: 0 }, + { id: 'ssh-ok', label: 'Ok', generation: 2 } + ]) + + // Why: a defaulted generation would fence an owner against a registration + // that never existed; absent degrades that one host to view-only instead. + const generations = store.getState().sshTargetGenerations + expect(generations.has('ssh-legacy')).toBe(false) + expect(generations.has('ssh-bad')).toBe(false) + expect(generations.get('ssh-ok')).toBe(2) + }) + + it('refreshes generations when a host is re-registered under a new id', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote', generation: 4 }]) + + // Remove-and-re-add issues a fresh id and a fresh generation; the mirror has + // to follow, or the re-added host stays view-only for the rest of the session. + store.getState().setSshTargetsMetadata([{ id: 'ssh-2', label: 'Remote', generation: 9 }]) + + expect(store.getState().sshTargetGenerations.has('ssh-1')).toBe(false) + expect(store.getState().sshTargetGenerations.get('ssh-2')).toBe(9) + }) + + it('refreshes a generation re-registered under the same id', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([{ id: 'ssh-runtime-owned-r1', label: 'Env' }]) + store + .getState() + .setSshTargetsMetadata([{ id: 'ssh-runtime-owned-r1', label: 'Env', generation: 4 }]) + + // Runtime-owned targets keep a deterministic id, so a re-upsert after removal + // reissues a *new* generation under the *old* key — the one re-registration + // shape a key-set comparison would miss, freezing the host view-only. + store + .getState() + .setSshTargetsMetadata([{ id: 'ssh-runtime-owned-r1', label: 'Env', generation: 11 }]) + + expect(store.getState().sshTargetGenerations.get('ssh-runtime-owned-r1')).toBe(11) + }) + + it('adopts generations when a prior load mirrored the same labels without them', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote' }]) + + // Why: labels alone would satisfy the unchanged-list early return and freeze + // the mirror empty, leaving the host view-only against a known generation. + store.getState().setSshTargetsMetadata([{ id: 'ssh-1', label: 'Remote', generation: 3 }]) + + expect(store.getState().sshTargetGenerations.get('ssh-1')).toBe(3) + }) + + it('drops a removed target generation so a dead registration stays unfenceable', () => { + const store = createTestStore() + store.getState().setSshTargetsMetadata([ + { id: 'ssh-1', label: 'Remote', generation: 4 }, + { id: 'ssh-2', label: 'Other', generation: 7 } + ]) + + store.getState().clearRemovedSshTargetState('ssh-1') + + expect(store.getState().sshTargetGenerations.has('ssh-1')).toBe(false) + expect(store.getState().sshTargetGenerations.get('ssh-2')).toBe(7) + }) + it('marks targets hydrated on the first load, even when the list is empty', () => { const store = createTestStore() expect(store.getState().sshTargetsHydrated).toBe(false) diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 9e15c81ea40..7f5ee70cd79 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -4,11 +4,13 @@ import type { SshConnectionState, PortForwardEntry, EnrichedDetectedPort, - SshTarget + SshTargetSummary } from '../../../../shared/ssh-types' import { buildRemovedSshTargetCleanupPatch, + collectSshTargetGenerations, sshConnectionStatesEqual, + sshTargetGenerationsEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' @@ -33,6 +35,16 @@ export type SshSlice = { /** Maps target IDs to their user-facing labels. Populated during hydration * so components can look up labels without per-component IPC calls. */ sshTargetLabels: Map + /** + * Maps target IDs to their *registration* generations — the incarnation an + * owner is fenced against, not `SshConnectionState.connectionGeneration`. + * + * Mirrored here for the same reason as the labels, and mirrored by the runtime + * buckets already: only a generation makes a desktop SSH host fenceable, so a + * consumer without it degrades every such host to view-only. A target with no + * usable generation is simply absent — see `collectSshTargetGenerations`. + */ + sshTargetGenerations: Map /** Maps REMOVED target IDs to their last known label (from re-adoption * tombstones). Lets ghost-host UI show a friendly name instead of the raw id * for a workspace still pinned to a deleted target. */ @@ -60,7 +72,7 @@ export type SshSlice = { setSshConnectionState: (targetId: string, state: SshConnectionState) => void setSshTargetLabels: (labels: Map) => void setRemovedSshTargetLabels: (labels: Record) => void - setSshTargetsMetadata: (targets: Pick[]) => void + setSshTargetsMetadata: (targets: SshTargetSummary[]) => void clearRemovedSshTargetState: (targetId: string) => void markRemoteWorkspaceHydrated: (targetId: string) => void clearRemoteWorkspaceHydrated: (targetId: string) => void @@ -85,6 +97,7 @@ function advanceLocalSshTargetConnectionGeneration(targetId: string): void { export const createSshSlice: StateCreator = (set) => ({ sshConnectionStates: new Map(), sshTargetLabels: new Map(), + sshTargetGenerations: new Map(), removedSshTargetLabels: new Map(), sshTargetsHydrated: false, remoteWorkspaceHydratedTargetIds: new Set(), @@ -123,13 +136,20 @@ export const createSshSlice: StateCreator = (set) => set({ removedSshTargetLabels: new Map(Object.entries(labels)) }), setSshTargetsMetadata: (targets) => set((s) => { - if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) { + const sshTargetGenerations = collectSshTargetGenerations(targets) + // Both maps gate the early return: a caller that first hydrated through a + // generation-less path would otherwise be frozen out by matching labels. + if ( + sshTargetLabelsEqual(s.sshTargetLabels, targets) && + sshTargetGenerationsEqual(s.sshTargetGenerations, sshTargetGenerations) + ) { // Why: an unchanged (even empty) list is still a successful load — the // hydration flag must flip on the first fetch of an empty target set. return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true } } return { sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])), + sshTargetGenerations, sshTargetsHydrated: true } }), diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 8a36430b099..151cbb7e801 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -89,6 +89,12 @@ import { normalizeBrowserPageZoomLevel } from '../../../../shared/browser-page-zoom' import { persistedUIValuesEqual } from '../../../../shared/persisted-ui-equality' +import { + ALL_AUTOMATION_HOSTS_FILTER, + parsePersistedAutomationHostFilter, + toPersistedAutomationHostFilter, + type AutomationHostFilter +} from '../../../../shared/automation-host-filter' import { normalizeExecutionHostOrder, normalizeExecutionHostScope, @@ -897,6 +903,9 @@ export type UISlice = { setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void workspaceHostOrder: WorkspaceHostOrder setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void + /** Automations page host filter, in stable form. Never written from an unhydrated catalog. */ + automationHostFilter: AutomationHostFilter + setAutomationHostFilter: (filter: AutomationHostFilter) => void manualRepoOrder: ManualRepoOrderEntry[] hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void @@ -2116,6 +2125,13 @@ export const createUISlice: StateCreator = (set, get) set({ workspaceHostOrder }) window.api.ui.set({ workspaceHostOrder }).catch(console.error) }, + automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, + setAutomationHostFilter: (filter) => { + window.api.ui + .set({ automationHostFilter: toPersistedAutomationHostFilter(filter) }) + .catch(console.error) + set({ automationHostFilter: filter }) + }, manualRepoOrder: [], hideDefaultBranchWorkspace: false, @@ -2530,6 +2546,8 @@ export const createUISlice: StateCreator = (set, get) workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), + // Why: a malformed or legacy filter value must degrade to All hosts, never throw during hydration. + automationHostFilter: parsePersistedAutomationHostFilter(ui.automationHostFilter), manualRepoOrder, // Why: apply the desktop-owned overlay immediately since UI state can arrive after a catalog or from another client. repos: orderedRepos, diff --git a/src/shared/automation-enablement-decision.ts b/src/shared/automation-enablement-decision.ts new file mode 100644 index 00000000000..af5dbe7ac42 --- /dev/null +++ b/src/shared/automation-enablement-decision.ts @@ -0,0 +1,18 @@ +/** + * Who last decided an automation's `enabled` flag, as both processes read it. + * + * The main-side migration already answers this for itself + * (`automation-owner-migration.ts`), but the renderer cannot import from main — + * and without the distinction a record Orca switched off is indistinguishable + * from one the user switched off, which is the half of the orphan-disable + * complaint the mechanical fix did not answer. + */ + +import type { Automation } from './automations-types' + +/** Absent on every pre-stamp record, which must keep reading as a plain pause. */ +export function isAutomationDisabledByOwnerMigration( + automation: Pick +): boolean { + return !automation.enabled && automation.enabledDecidedBy === 'owner_migration' +} diff --git a/src/shared/automation-execution-target.test.ts b/src/shared/automation-execution-target.test.ts new file mode 100644 index 00000000000..aed37067980 --- /dev/null +++ b/src/shared/automation-execution-target.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { + MISSING_AUTOMATION_PROJECT_ERROR, + applyAutomationExecutionTarget, + deriveAutomationExecutionTargetForCreate, + deriveAutomationExecutionTargetForUpdate, + type AutomationStoredExecutionTarget +} from './automation-execution-target' + +const storedSsh: AutomationStoredExecutionTarget = { + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 4 +} + +/** SSH-owned through its folder workspace, not through its project. */ +const storedPinnedLocal: AutomationStoredExecutionTarget = { + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 4 +} + +describe('deriveAutomationExecutionTargetForCreate', () => { + it('fails closed when the project does not resolve', () => { + expect(() => + deriveAutomationExecutionTargetForCreate({ repo: undefined, sshTargetGeneration: undefined }) + ).toThrow(MISSING_AUTOMATION_PROJECT_ERROR) + }) + + it('stores the current registration generation for an SSH project', () => { + expect( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId: 'ssh-1' }, + sshTargetGeneration: 4 + }) + ).toEqual({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 4 + }) + }) + + it('omits the generation for a local project', () => { + expect( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId: null }, + sshTargetGeneration: 9 + }) + ).toEqual({ executionTargetType: 'local', executionTargetId: 'local' }) + }) + + it('captures the pinned registration for a local project whose workspace runs on SSH', () => { + expect( + deriveAutomationExecutionTargetForCreate({ + repo: { connectionId: null }, + sshTargetGeneration: undefined, + workspaceSshPin: { targetId: 'ssh-1', generation: 4 } + }) + ).toEqual({ + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 4 + }) + }) +}) + +describe('deriveAutomationExecutionTargetForUpdate', () => { + it('preserves the stored SSH selector when the owning project was deleted', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: undefined, + selectorMoveRequested: false, + sshTargetGeneration: undefined + }) + ).toEqual(storedSsh) + }) + + // The repo resolving is not consent: this is the pause-an-orphan path. + it('preserves the stored selector when the resolved project points somewhere else', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: { connectionId: 'ssh-2' }, + selectorMoveRequested: false, + sshTargetGeneration: 11 + }) + ).toEqual(storedSsh) + }) + + it('keeps a captured generation the current target no longer has', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: { connectionId: 'ssh-1' }, + selectorMoveRequested: false, + sshTargetGeneration: undefined + }) + ).toEqual(storedSsh) + }) + + it('throws when a requested move has no resolvable project', () => { + expect(() => + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: undefined, + selectorMoveRequested: true, + sshTargetGeneration: undefined + }) + ).toThrow(MISSING_AUTOMATION_PROJECT_ERROR) + }) + + it('re-derives from a resolved project once a move is requested', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedSsh, + repo: { connectionId: 'ssh-2' }, + selectorMoveRequested: true, + sshTargetGeneration: 11 + }) + ).toEqual({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-2', + executionTargetGeneration: 11 + }) + }) + + it('captures the new pin when the update re-points the record at another workspace', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedPinnedLocal, + repo: { connectionId: null }, + selectorMoveRequested: false, + sshTargetGeneration: undefined, + workspaceSshPin: { targetId: 'ssh-2', generation: 11 }, + workspaceSshPinMoved: true + }) + ).toEqual({ + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 11 + }) + }) + + it('drops the capture when the update leaves the record unpinned', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedPinnedLocal, + repo: { connectionId: null }, + selectorMoveRequested: false, + sshTargetGeneration: undefined, + workspaceSshPin: undefined, + workspaceSshPinMoved: true + }) + ).toEqual({ executionTargetType: 'local', executionTargetId: 'local' }) + }) + + it('keeps the capture while the pin is unchanged', () => { + expect( + deriveAutomationExecutionTargetForUpdate({ + current: storedPinnedLocal, + repo: { connectionId: null }, + selectorMoveRequested: false, + sshTargetGeneration: undefined, + workspaceSshPin: { targetId: 'ssh-1', generation: 4 }, + workspaceSshPinMoved: false + }) + ).toEqual(storedPinnedLocal) + }) +}) + +describe('applyAutomationExecutionTarget', () => { + it('clears a stale generation when the record moves off SSH', () => { + const next = applyAutomationExecutionTarget( + { ...storedSsh, name: 'keep me' }, + { executionTargetType: 'local', executionTargetId: 'local' } + ) + expect(next).toEqual({ + executionTargetType: 'local', + executionTargetId: 'local', + name: 'keep me' + }) + expect(Object.hasOwn(next, 'executionTargetGeneration')).toBe(false) + }) + + // Losing it here is what made a same-id re-registration read as the host the user chose. + it('keeps the capture when the destination is still pinned to the same SSH target', () => { + const next = applyAutomationExecutionTarget( + { ...storedPinnedLocal, name: 'keep me' }, + { executionTargetType: 'local', executionTargetId: 'local' }, + { targetId: 'ssh-1', generation: undefined } + ) + expect(next.executionTargetGeneration).toBe(4) + }) +}) diff --git a/src/shared/automation-execution-target.ts b/src/shared/automation-execution-target.ts new file mode 100644 index 00000000000..09b8350f24c --- /dev/null +++ b/src/shared/automation-execution-target.ts @@ -0,0 +1,136 @@ +import type { Automation, AutomationExecutionTargetType } from './automations-types' +import type { AutomationWorkspaceSshPin } from './automation-workspace-pin' + +/** + * Decides which execution target an automation is stored against. + * + * The old rule (`repo?.connectionId ? 'ssh' : 'local'`) silently wrote the + * literal `local` whenever the repo lookup missed, so deleting a project turned + * an SSH automation into a local one that would then run on the wrong host. + * Both paths now fail closed instead: create refuses an unresolvable project, + * and update preserves the stored selector unless the caller explicitly asked + * to move the record to a project that does resolve. + * + * A `local` record whose folder workspace pins it to an SSH host captures that + * registration's generation too. Without a capture, `captured ?? current` always + * reads the *current* generation, so a host removed and re-added under the same + * id can never be told from the one the user chose. + */ + +export type AutomationExecutionTargetSelection = { + executionTargetType: AutomationExecutionTargetType + executionTargetId: string + executionTargetGeneration?: number +} + +export type AutomationExecutionTargetRepo = { connectionId?: string | null } + +export type AutomationStoredExecutionTarget = Pick< + Automation, + 'executionTargetType' | 'executionTargetId' | 'executionTargetGeneration' +> + +export const MISSING_AUTOMATION_PROJECT_ERROR = + "This automation's project no longer exists, so its host cannot be resolved." + +function withGeneration( + selection: Omit, + generation: number | undefined +): AutomationExecutionTargetSelection { + return { + ...selection, + ...(generation === undefined ? {} : { executionTargetGeneration: generation }) + } +} + +function selectionForRepo( + repo: AutomationExecutionTargetRepo, + sshTargetGeneration: number | undefined, + workspaceSshPin: AutomationWorkspaceSshPin | undefined +): AutomationExecutionTargetSelection { + const connectionId = repo.connectionId?.trim() + if (!connectionId) { + // A pinned workspace is the record's real host, so the local target captures its generation. + return withGeneration( + { executionTargetType: 'local', executionTargetId: 'local' }, + workspaceSshPin?.generation + ) + } + return withGeneration( + { executionTargetType: 'ssh', executionTargetId: connectionId }, + sshTargetGeneration + ) +} + +/** + * Apply a derived selection, clearing a stale generation when the record moves + * off SSH. + * + * `retainedWorkspaceSshPin` is the pin the record keeps across the move. Its + * capture is evidence of one registration incarnation and survives even when the + * pinned target is currently unregistered — dropping it there would let a later + * same-id registration read as the host the user chose. + */ +export function applyAutomationExecutionTarget( + record: T, + selection: AutomationExecutionTargetSelection, + retainedWorkspaceSshPin?: AutomationWorkspaceSshPin +): T { + const next = { ...record, ...selection } + if (selection.executionTargetGeneration === undefined && !retainedWorkspaceSshPin) { + delete next.executionTargetGeneration + } + return next +} + +/** Create fails closed: an automation with no resolvable project has no derivable host. */ +export function deriveAutomationExecutionTargetForCreate(input: { + repo: AutomationExecutionTargetRepo | undefined + sshTargetGeneration: number | undefined + workspaceSshPin?: AutomationWorkspaceSshPin +}): AutomationExecutionTargetSelection { + if (!input.repo) { + throw new Error(MISSING_AUTOMATION_PROJECT_ERROR) + } + return selectionForRepo(input.repo, input.sshTargetGeneration, input.workspaceSshPin) +} + +/** + * Update re-derives only when the caller asked to move the record, and then + * fails closed on an unresolvable project. + * + * A resolved repo is not a request to move: re-deriving from it re-adopts an + * orphan onto whatever registration that repo points at today — which pause, + * still enabled on orphans, would silently do — and strips a captured + * generation whenever the current target has none. + */ +export function deriveAutomationExecutionTargetForUpdate(input: { + current: AutomationStoredExecutionTarget + repo: AutomationExecutionTargetRepo | undefined + /** An explicit `projectId` or an explicit destination; nothing else moves a selector. */ + selectorMoveRequested: boolean + sshTargetGeneration: number | undefined + /** The SSH target the record's workspace pins it to once the update lands. */ + workspaceSshPin?: AutomationWorkspaceSshPin + /** The update re-points the record at a different pinned target, or off one entirely. */ + workspaceSshPinMoved?: boolean +}): AutomationExecutionTargetSelection { + if (!input.selectorMoveRequested) { + // Re-pointing the workspace moves a local record's host, so its capture moves with it. + const generation = + input.current.executionTargetType === 'local' && input.workspaceSshPinMoved + ? input.workspaceSshPin?.generation + : input.current.executionTargetGeneration + return withGeneration( + { + executionTargetType: input.current.executionTargetType, + executionTargetId: input.current.executionTargetId + }, + generation + ) + } + if (!input.repo) { + throw new Error(MISSING_AUTOMATION_PROJECT_ERROR) + } + return selectionForRepo(input.repo, input.sshTargetGeneration, input.workspaceSshPin) +} diff --git a/src/shared/automation-host-filter.test.ts b/src/shared/automation-host-filter.test.ts new file mode 100644 index 00000000000..f6b2b74d202 --- /dev/null +++ b/src/shared/automation-host-filter.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { hostStableKey } from './automation-owner-key' +import type { StableAutomationCatalogRef } from './automation-owner-ref' +import { + ALL_AUTOMATION_HOSTS_FILTER, + automationHostFilterStableKey, + automationHostFiltersEqual, + parsePersistedAutomationHostFilter, + toPersistedAutomationHostFilter, + type AutomationHostFilter +} from './automation-host-filter' + +const sshHost: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'ssh:a' } +} +const sshFilter: AutomationHostFilter = { kind: 'host', host: sshHost } + +describe('persisted automation host filter', () => { + it('persists only the stable form as a canonical key', () => { + expect(toPersistedAutomationHostFilter(sshFilter)).toEqual({ + kind: 'host', + hostKey: hostStableKey(sshHost) + }) + expect(toPersistedAutomationHostFilter(ALL_AUTOMATION_HOSTS_FILTER)).toEqual({ kind: 'all' }) + }) + + it('round-trips every stable selector kind', () => { + const filters: AutomationHostFilter[] = [ + { kind: 'host', host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } } }, + { kind: 'host', host: { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } } }, + { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'a:b' } } + }, + sshFilter + ] + for (const filter of filters) { + expect(parsePersistedAutomationHostFilter(toPersistedAutomationHostFilter(filter))).toEqual( + filter + ) + } + }) + + it.each([ + undefined, + null, + 'host:desktop:self', + 42, + {}, + { kind: 'host' }, + { kind: 'host', hostKey: 5 }, + { kind: 'host', hostKey: '' }, + { kind: 'host', hostKey: 'desktop:self' }, + { kind: 'host', hostKey: 'host:runtime' }, + { kind: 'host', hostKey: 'host:desktop:bogus' }, + { kind: 'host', hostKey: 'host:desktop:ssh:%zz' }, + { kind: 'nonsense' } + ])('falls back to All hosts for malformed value %j without throwing', (value) => { + expect(parsePersistedAutomationHostFilter(value)).toEqual({ kind: 'all' }) + }) + + it('exposes the stable key and compares by it', () => { + expect(automationHostFilterStableKey(ALL_AUTOMATION_HOSTS_FILTER)).toBeNull() + expect(automationHostFilterStableKey(sshFilter)).toBe(hostStableKey(sshHost)) + expect(automationHostFiltersEqual(sshFilter, { kind: 'host', host: { ...sshHost } })).toBe(true) + expect(automationHostFiltersEqual(sshFilter, ALL_AUTOMATION_HOSTS_FILTER)).toBe(false) + }) + + it('does not collide equal target ids across authorities', () => { + const desktop: AutomationHostFilter = { + kind: 'host', + host: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'shared' } } + } + const runtime: AutomationHostFilter = { + kind: 'host', + host: { + authority: { kind: 'runtime', environmentId: 'env-1' }, + selector: { kind: 'ssh', targetId: 'shared' } + } + } + expect(automationHostFiltersEqual(desktop, runtime)).toBe(false) + }) +}) diff --git a/src/shared/automation-host-filter.ts b/src/shared/automation-host-filter.ts new file mode 100644 index 00000000000..11cd6222001 --- /dev/null +++ b/src/shared/automation-host-filter.ts @@ -0,0 +1,52 @@ +import { hostStableKey, parseHostStableKey } from './automation-owner-key' +import type { StableAutomationCatalogRef } from './automation-owner-ref' + +/** + * The Automations page host filter. + * + * Only the *stable* form is ever persisted: a rename, reconnect, runtime + * re-pair, or SSH reconnect must not lose the user's selection, while data and + * actions captured from an old incarnation still have to be discarded. + */ +export type AutomationHostFilter = + | { kind: 'all' } + | { kind: 'host'; host: StableAutomationCatalogRef } + +/** On-disk shape: the canonical `hostStableKey` string plus a discriminator. */ +export type PersistedAutomationHostFilter = { kind: 'all' } | { kind: 'host'; hostKey: string } + +/** Shared identity so an unchanged hydration can return the same reference. */ +export const ALL_AUTOMATION_HOSTS_FILTER: AutomationHostFilter = Object.freeze({ kind: 'all' }) + +export function toPersistedAutomationHostFilter( + filter: AutomationHostFilter +): PersistedAutomationHostFilter { + return filter.kind === 'all' + ? { kind: 'all' } + : { kind: 'host', hostKey: hostStableKey(filter.host) } +} + +/** Never throws: a malformed, legacy, or hand-edited value degrades to All hosts. */ +export function parsePersistedAutomationHostFilter(value: unknown): AutomationHostFilter { + if (!value || typeof value !== 'object') { + return ALL_AUTOMATION_HOSTS_FILTER + } + const record = value as Partial> + if (record.kind !== 'host' || typeof record.hostKey !== 'string') { + return ALL_AUTOMATION_HOSTS_FILTER + } + const host = parseHostStableKey(record.hostKey) + return host ? { kind: 'host', host } : ALL_AUTOMATION_HOSTS_FILTER +} + +/** Stable key of the selected host, or null under All hosts. */ +export function automationHostFilterStableKey(filter: AutomationHostFilter): string | null { + return filter.kind === 'all' ? null : hostStableKey(filter.host) +} + +export function automationHostFiltersEqual( + a: AutomationHostFilter, + b: AutomationHostFilter +): boolean { + return automationHostFilterStableKey(a) === automationHostFilterStableKey(b) +} diff --git a/src/shared/automation-legacy-list-partition.test.ts b/src/shared/automation-legacy-list-partition.test.ts new file mode 100644 index 00000000000..635ff715633 --- /dev/null +++ b/src/shared/automation-legacy-list-partition.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from './automation-list-scope' +import { partitionLegacyAutomationList } from './automation-legacy-list-partition' + +function automation(overrides: Partial = {}): 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 context = { + repoConnectionId: (repoId: string) => + repoId === 'repo-local' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined, + projectsAuthoritative: true +} + +/** What a client holds about a foreign authority: a mirror that may simply not be loaded. */ +const mirroredContext = { ...context, projectsAuthoritative: false } + +describe('partitionLegacyAutomationList', () => { + it('sends a local record with a resolvable local project to self', () => { + const [row] = partitionLegacyAutomationList([automation()], context).rows + expect(row?.selector).toEqual({ kind: 'self' }) + }) + + it('sends a valid SSH record to its target with no generation attached', () => { + const [row] = partitionLegacyAutomationList( + [automation({ executionTargetType: 'ssh', executionTargetId: 'ssh-1' })], + context + ).rows + expect(row?.selector).toEqual({ kind: 'ssh', targetId: 'ssh-1' }) + }) + + it('orphans unknown types, missing SSH ids, and unresolvable projects', () => { + const partition = partitionLegacyAutomationList( + [ + automation({ id: 'x', executionTargetType: 'wat' as Automation['executionTargetType'] }), + automation({ id: 'y', executionTargetType: 'ssh', executionTargetId: ' ' }), + automation({ id: 'z', projectId: 'repo-gone' }) + ], + context + ) + expect(partition.rows.map((row) => row.selector.kind)).toEqual(['orphan', 'orphan', 'orphan']) + expect(partition.rows[2]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.projectMissing + }) + expect(partition.orphanCount).toBe(3) + }) + + it('orphans a record scheduled against a runtime instead of moving it', () => { + const partition = partitionLegacyAutomationList( + [automation({ schedulerOwner: 'remote_host_service' })], + context + ) + expect(partition.rows[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere + }) + }) + + it('reports an unresolved project as unverified when the table is only a mirror', () => { + const partition = partitionLegacyAutomationList( + [automation({ projectId: 'repo-gone' })], + mirroredContext + ) + expect(partition.rows[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.projectUnverified + }) + }) + + it('still requires positive evidence for self against a mirrored table', () => { + const [row] = partitionLegacyAutomationList([automation()], mirroredContext).rows + expect(row?.selector).toEqual({ kind: 'self' }) + }) + + it('does not read a bare local value as self when the project is hosted over SSH', () => { + const partition = partitionLegacyAutomationList( + [automation({ projectId: 'repo-ssh' })], + context + ) + expect(partition.rows[0]?.selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.malformed + }) + }) +}) diff --git a/src/shared/automation-legacy-list-partition.ts b/src/shared/automation-legacy-list-partition.ts new file mode 100644 index 00000000000..d098cce2a28 --- /dev/null +++ b/src/shared/automation-legacy-list-partition.ts @@ -0,0 +1,90 @@ +/** + * Partitions the unscoped list an old authority returns. + * + * Nothing here guesses: Self needs positive evidence (a `local` record whose + * project resolves with no SSH connection), a valid SSH record goes to its + * target even when that target is only a ghost, and everything contradictory + * lands in the orphan bucket. Legacy rows carry no registration generation, so + * they are keyed by target ID alone and stay view-only until the authority + * advertises owner fencing. + * + * The project evidence must come from the answering authority's own registry. + * When the caller can only offer a mirror of it, an unresolved project is + * reported as unverified: not knowing where a record lives is not the same + * diagnosis as knowing its project was deleted. + */ + +import type { Automation } from './automations-types' +import { AUTOMATION_ORPHAN_ISSUES } from './automation-list-scope' +import { getAutomationRunRepoId } from './automation-run-identity' +import { parseExecutionHostId } from './execution-host' + +export type LegacyAutomationSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string } + | { kind: 'orphan'; issue: string } + +export type LegacyAutomationRow = { + automation: Automation + selector: LegacyAutomationSelector +} + +export type LegacyAutomationPartition = { + rows: LegacyAutomationRow[] + orphanCount: number +} + +export type LegacyAutomationPartitionContext = { + /** `undefined` = this table has no such repo; `null` = a local repo; a string = its SSH connection. */ + repoConnectionId: (repoId: string) => string | null | undefined + /** + * Whether the table above is the answering authority's own project registry. + * False for a table this client only mirrors, where a miss is an unread answer + * rather than a deleted project. + */ + projectsAuthoritative: boolean +} + +function classify( + automation: Automation, + context: LegacyAutomationPartitionContext +): LegacyAutomationSelector { + if ( + automation.schedulerOwner === 'remote_host_service' || + parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime' + ) { + return { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere } + } + if (automation.executionTargetType === 'ssh') { + const targetId = automation.executionTargetId?.trim() + return targetId + ? { kind: 'ssh', targetId } + : { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed } + } + if (automation.executionTargetType !== 'local') { + return { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed } + } + const connectionId = context.repoConnectionId(getAutomationRunRepoId(automation)) + if (connectionId === undefined) { + return { + kind: 'orphan', + issue: context.projectsAuthoritative + ? AUTOMATION_ORPHAN_ISSUES.projectMissing + : AUTOMATION_ORPHAN_ISSUES.projectUnverified + } + } + return connectionId + ? { kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed } + : { kind: 'self' } +} + +export function partitionLegacyAutomationList( + automations: readonly Automation[], + context: LegacyAutomationPartitionContext +): LegacyAutomationPartition { + const rows = automations.map((automation) => ({ + automation, + selector: classify(automation, context) + })) + return { rows, orphanCount: rows.filter((row) => row.selector.kind === 'orphan').length } +} diff --git a/src/shared/automation-list-response.test.ts b/src/shared/automation-list-response.test.ts new file mode 100644 index 00000000000..19f20cd23d4 --- /dev/null +++ b/src/shared/automation-list-response.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { validateAutomationListResponse } from './automation-list-response' +import type { AutomationListScopeSelector } from './automation-list-scope' + +const SELF: AutomationListScopeSelector = { kind: 'self' } + +function response(overrides: Record = {}) { + return { + automations: [{ id: 'a1' }, { id: 'a2' }], + items: [ + { automationId: 'a1', selector: { kind: 'self' } }, + { automationId: 'a2', selector: { kind: 'self' } } + ], + orphanCount: 3, + ...overrides + } +} + +describe('validateAutomationListResponse', () => { + it('accepts a capable response and keeps the reported orphan count', () => { + const result = validateAutomationListResponse(response(), SELF) + expect(result).toMatchObject({ ok: true, invalidRows: 0 }) + expect(result.ok && result.result.orphanCount).toBe(3) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a1', 'a2']) + }) + + // Why: the field is optional on the wire, so "not reported" must stay + // distinguishable from "authoritatively none" — a fabricated zero settles the + // authority's orphan bucket that nobody counted. + it('leaves an omitted orphan count unreported instead of committing zero', () => { + const raw = response() + delete (raw as { orphanCount?: number }).orphanCount + const result = validateAutomationListResponse(raw, SELF) + expect(result).toMatchObject({ ok: true, invalidRows: 0 }) + expect(result.ok && result.result.orphanCount).toBeUndefined() + expect(result.ok && 'orphanCount' in result.result).toBe(false) + }) + + it('reports a legacy-shaped payload as unsupported host scope', () => { + const result = validateAutomationListResponse({ automations: [{ id: 'a1' }] }, SELF) + expect(result).toMatchObject({ ok: false, error: { code: 'unsupported_host_scope' } }) + }) + + it('rejects a malformed top-level response', () => { + expect(validateAutomationListResponse(null, SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + expect(validateAutomationListResponse({ automations: 'nope' }, SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + expect(validateAutomationListResponse({ automations: [], items: {} }, SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + expect(validateAutomationListResponse(response({ orphanCount: -1 }), SELF)).toMatchObject({ + ok: false, + error: { code: 'invalid_response' } + }) + }) + + it('drops one unmatched row without hiding the rest of the host', () => { + const result = validateAutomationListResponse( + response({ items: [{ automationId: 'a1', selector: { kind: 'self' } }] }), + SELF + ) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a1']) + expect(result.ok && result.invalidRows).toBe(1) + }) + + it('drops both rows when one automation id carries two metadata items', () => { + const result = validateAutomationListResponse( + response({ + items: [ + { automationId: 'a1', selector: { kind: 'self' } }, + { automationId: 'a1', selector: { kind: 'ssh', targetId: 't', targetGeneration: 1 } }, + { automationId: 'a2', selector: { kind: 'self' } } + ] + }), + SELF + ) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(result.ok && result.invalidRows).toBe(2) + }) + + it('never reclassifies a row whose selector does not match the request', () => { + const result = validateAutomationListResponse( + response({ + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 2 } }, + { automationId: 'a2', selector: { kind: 'self' } } + ] + }), + SELF + ) + expect(result.ok && result.result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(result.ok && result.result.items[0]?.selector).toEqual({ kind: 'self' }) + expect(result.ok && result.invalidRows).toBe(1) + }) + + it('requires an SSH item to carry the requested generation', () => { + const scope: AutomationListScopeSelector = { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 4 + } + const stale = validateAutomationListResponse( + response({ + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 3 } }, + { automationId: 'a2', selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 4 } } + ] + }), + scope + ) + expect(stale.ok && stale.result.automations.map((entry) => entry.id)).toEqual(['a2']) + expect(stale.ok && stale.invalidRows).toBe(1) + }) + + it('drops rows whose metadata is structurally unusable', () => { + const result = validateAutomationListResponse( + response({ + automations: [{ id: 'a1' }, { name: 'no id' }], + items: [ + { automationId: 'a1', selector: { kind: 'ssh', targetId: 'ssh-1' } }, + { selector: { kind: 'self' } } + ] + }), + SELF + ) + expect(result.ok && result.result.automations).toEqual([]) + expect(result.ok && result.invalidRows).toBe(4) + }) + + it('keeps a row whose usage summary is unusable but blanks the summary', () => { + const result = validateAutomationListResponse( + response({ + automations: [{ id: 'a1' }], + items: [{ automationId: 'a1', selector: { kind: 'self' }, usageSummary: { knownRuns: 1 } }] + }), + SELF + ) + expect(result.ok && result.result.items[0]?.usageSummary).toBeNull() + expect(result.ok && result.invalidRows).toBe(0) + }) +}) diff --git a/src/shared/automation-list-response.ts b/src/shared/automation-list-response.ts new file mode 100644 index 00000000000..57475d4e48a --- /dev/null +++ b/src/shared/automation-list-response.ts @@ -0,0 +1,177 @@ +/** + * Boundary validation for an `automation.list` response. + * + * A client validates rather than casts: a host that answers a scoped request + * with the legacy `{ automations }` shape is reporting the whole authority, and + * committing that as one host's rows would attribute other hosts' automations to + * the selected one. Individual malformed rows are dropped and counted so one bad + * row neither hides a host nor produces an unqualified row, and a returned + * selector that does not match the request is never reclassified into Self. + */ + +import type { Automation } from './automations-types' +import type { AutomationUsageSummary } from './automation-usage-summary' +import { + automationSelectorMatchesScope, + type AutomationListItem, + type AutomationListItemSelector, + type AutomationListResult, + type AutomationListScopeSelector +} from './automation-list-scope' + +export type AutomationListResponseFailure = { + code: 'unsupported_host_scope' | 'invalid_response' + message: string +} + +export type AutomationListResponseValidation = + | { ok: true; result: AutomationListResult; invalidRows: number } + | { ok: false; error: AutomationListResponseFailure } + +const UNSUPPORTED_HOST_SCOPE_MESSAGE = + 'This host does not support per-host automation lists. Update the Orca server to filter by host.' +const INVALID_RESPONSE_MESSAGE = 'This host returned an automation list Orca could not read.' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseAutomation(value: unknown): Automation | null { + return isRecord(value) && typeof value.id === 'string' && value.id.length > 0 + ? (value as Automation) + : null +} + +const USAGE_SUMMARY_FIELDS = [ + 'knownRuns', + 'unavailableRuns', + 'inputTokens', + 'outputTokens', + 'cacheTokens', + 'reasoningOutputTokens', + 'totalTokens' +] as const + +function parseUsageSummary(value: unknown): AutomationUsageSummary | null { + if (!isRecord(value)) { + return null + } + if (!USAGE_SUMMARY_FIELDS.every((field) => typeof value[field] === 'number')) { + return null + } + const cost = value.estimatedCostUsd + return cost === null || typeof cost === 'number' ? (value as AutomationUsageSummary) : null +} + +function parseSelector(value: unknown): AutomationListItemSelector | null { + if (!isRecord(value)) { + return null + } + if (value.kind === 'self') { + return { kind: 'self' } + } + if (value.kind === 'orphan') { + return { kind: 'orphan', issue: typeof value.issue === 'string' ? value.issue : '' } + } + if ( + value.kind !== 'ssh' || + typeof value.targetId !== 'string' || + value.targetId.length === 0 || + !Number.isSafeInteger(value.targetGeneration) + ) { + return null + } + return { + kind: 'ssh', + targetId: value.targetId, + targetGeneration: value.targetGeneration as number + } +} + +type ParsedItems = { byId: Map; invalidRows: number } + +function parseItems(raw: readonly unknown[]): ParsedItems { + const byId = new Map() + const duplicated = new Set() + let invalidRows = 0 + for (const entry of raw) { + const selector = isRecord(entry) ? parseSelector(entry.selector) : null + const automationId = + isRecord(entry) && typeof entry.automationId === 'string' && entry.automationId.length > 0 + ? entry.automationId + : null + if (!selector || !automationId) { + invalidRows++ + continue + } + if (byId.has(automationId) || duplicated.has(automationId)) { + // A second item for one ID makes both unusable: neither can be trusted as the row's owner. + byId.delete(automationId) + duplicated.add(automationId) + invalidRows++ + continue + } + byId.set(automationId, { + automationId, + selector, + usageSummary: parseUsageSummary((entry as { usageSummary?: unknown }).usageSummary) + }) + } + return { byId, invalidRows } +} + +function failure(code: AutomationListResponseFailure['code']): AutomationListResponseValidation { + return { + ok: false, + error: { + code, + message: + code === 'unsupported_host_scope' + ? UNSUPPORTED_HOST_SCOPE_MESSAGE + : INVALID_RESPONSE_MESSAGE + } + } +} + +export function validateAutomationListResponse( + raw: unknown, + scope: AutomationListScopeSelector +): AutomationListResponseValidation { + if (!isRecord(raw) || !Array.isArray(raw.automations)) { + return failure('invalid_response') + } + if (raw.items === undefined || raw.items === null) { + return failure('unsupported_host_scope') + } + if (!Array.isArray(raw.items)) { + return failure('invalid_response') + } + const orphanCount = typeof raw.orphanCount === 'number' ? raw.orphanCount : undefined + if ( + raw.orphanCount !== undefined && + (orphanCount === undefined || !Number.isSafeInteger(orphanCount) || orphanCount < 0) + ) { + return failure('invalid_response') + } + const parsedItems = parseItems(raw.items) + let invalidRows = parsedItems.invalidRows + const automations: Automation[] = [] + const items: AutomationListItem[] = [] + for (const entry of raw.automations) { + const automation = parseAutomation(entry) + const item = automation ? parsedItems.byId.get(automation.id) : undefined + if (!automation || !item || !automationSelectorMatchesScope(item.selector, scope)) { + invalidRows++ + continue + } + automations.push(automation) + items.push(item) + } + return { + ok: true, + // Omitted rather than defaulted: a host is allowed not to report, and "not + // reported" must not read downstream as "authoritatively none". + result: { automations, items, ...(orphanCount === undefined ? {} : { orphanCount }) }, + invalidRows + } +} diff --git a/src/shared/automation-list-scope.test.ts b/src/shared/automation-list-scope.test.ts new file mode 100644 index 00000000000..740ce191638 --- /dev/null +++ b/src/shared/automation-list-scope.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import { + AUTOMATION_ORPHAN_ISSUES, + automationChangePublications, + automationSelectorMatchesScope, + projectAutomationList, + projectAutomationSelector, + toAutomationChangeSelector, + type AutomationProjectionContext +} from './automation-list-scope' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from './protocol-version' + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + 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: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +function context( + overrides: Partial = {} +): AutomationProjectionContext { + return { + sshTargetGeneration: (targetId) => (targetId === 'ssh-1' ? 7 : undefined), + repoConnectionId: (repoId) => + repoId === 'repo-1' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined, + ...overrides + } +} + +describe('capability advertisement', () => { + it('registers both automation capabilities so getStatus() advertises them', () => { + expect(RUNTIME_CAPABILITIES).toContain(AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY) + }) +}) + +describe('projectAutomationSelector', () => { + it('qualifies a local record whose project resolves without a connection as self', () => { + expect(projectAutomationSelector(automation(), context())).toEqual({ kind: 'self' }) + }) + + it('refuses to read a bare local value as self when the project is gone', () => { + expect(projectAutomationSelector(automation({ projectId: 'missing' }), context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.projectMissing + }) + }) + + it('treats a local record whose project points at an SSH connection as malformed', () => { + expect(projectAutomationSelector(automation({ projectId: 'repo-ssh' }), context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.malformed + }) + }) + + it('pins an SSH record to its registration generation', () => { + const record = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + projectId: 'repo-ssh' + }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'ssh', + targetId: 'ssh-1', + targetGeneration: 7 + }) + }) + + it('orphans an SSH record whose target was removed and re-added', () => { + const record = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 5 + }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetReplaced + }) + }) + + it('orphans an SSH record whose target is gone', () => { + const record = automation({ executionTargetType: 'ssh', executionTargetId: 'ssh-gone' }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + }) + + it('never assigns a runtime-scheduled record to another authority', () => { + const record = automation({ schedulerOwner: 'remote_host_service' }) + expect(projectAutomationSelector(record, context())).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere + }) + const byHostId = automation({ + runContext: { + kind: 'workspace-run', + projectId: 'p', + hostId: 'runtime:env-1', + projectHostSetupId: 's', + repoId: 'repo-1', + path: '/tmp' + } + }) + expect(projectAutomationSelector(byHostId, context()).kind).toBe('orphan') + }) +}) + +describe('automationSelectorMatchesScope', () => { + it('requires the SSH generation to match, not just the target id', () => { + const selector = { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } as const + expect( + automationSelectorMatchesScope(selector, { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 7 + }) + ).toBe(true) + expect( + automationSelectorMatchesScope(selector, { + kind: 'ssh', + targetId: 'ssh-1', + expectedTargetGeneration: 6 + }) + ).toBe(false) + }) +}) + +describe('projectAutomationList', () => { + const records = [ + automation({ id: 'local-1' }), + automation({ + id: 'ssh-a', + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + projectId: 'repo-ssh' + }), + automation({ id: 'orphan-1', executionTargetType: 'ssh', executionTargetId: 'ssh-gone' }) + ] + + it('returns every record with one item each when no scope is requested', () => { + const result = projectAutomationList(records, context()) + expect(result.automations).toHaveLength(3) + expect(result.items.map((item) => item.automationId)).toEqual(['local-1', 'ssh-a', 'orphan-1']) + expect(result.orphanCount).toBe(1) + }) + + it('narrows to one host while still reporting the authority orphan count', () => { + const result = projectAutomationList(records, context(), { kind: 'self' }) + expect(result.automations.map((entry) => entry.id)).toEqual(['local-1']) + expect(result.orphanCount).toBe(1) + }) + + it('attaches bounded usage summaries when the authority supplies them', () => { + const result = projectAutomationList(records, { + ...context(), + usageSummary: (id) => + id === 'local-1' + ? { + knownRuns: 2, + unavailableRuns: 0, + inputTokens: 1, + outputTokens: 2, + cacheTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 3, + estimatedCostUsd: null + } + : null + }) + expect(result.items[0]?.usageSummary?.knownRuns).toBe(2) + expect(result.items[1]?.usageSummary).toBeNull() + }) +}) + +describe('automationChangePublications', () => { + it('publishes one event when the record stays on its host', () => { + expect(automationChangePublications({ kind: 'self' }, { kind: 'self' })).toEqual([ + { kind: 'self' } + ]) + }) + + it('publishes source and destination when the record moves', () => { + expect(automationChangePublications({ kind: 'self' }, { kind: 'ssh', targetId: 't1' })).toEqual( + [{ kind: 'self' }, { kind: 'ssh', targetId: 't1' }] + ) + }) + + it('treats two SSH hosts as a move', () => { + expect( + automationChangePublications({ kind: 'ssh', targetId: 't1' }, { kind: 'ssh', targetId: 't2' }) + ).toEqual([ + { kind: 'ssh', targetId: 't1' }, + { kind: 'ssh', targetId: 't2' } + ]) + }) + + // Why: a partial publication would leave the unnamed side showing a row that moved away. + it('degrades to one unscoped event when either side is unknown', () => { + expect(automationChangePublications(null, { kind: 'self' })).toEqual([undefined]) + expect(automationChangePublications({ kind: 'self' }, null)).toEqual([undefined]) + }) + + it('drops the incarnation when narrowing a projected selector', () => { + expect( + toAutomationChangeSelector({ kind: 'ssh', targetId: 't1', targetGeneration: 9 }) + ).toEqual({ kind: 'ssh', targetId: 't1' }) + expect(toAutomationChangeSelector({ kind: 'orphan', issue: 'gone' })).toEqual({ + kind: 'orphan' + }) + }) +}) + +// A folder workspace can pin its host directly, and that pin is what dispatch uses. +// Projecting from the repo alone filed those records under Self while they ran elsewhere. +describe('workspace host pin', () => { + it('files a local record under the SSH host its workspace pins', () => { + const selector = projectAutomationSelector( + automation({ workspaceId: 'folder:fw-1' }), + context({ workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' }) }) + ) + + expect(selector).toEqual({ kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 }) + }) + + it('orphans a pin whose SSH target is no longer registered', () => { + const selector = projectAutomationSelector( + automation({ workspaceId: 'folder:fw-1' }), + context({ workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-gone' }) }) + ) + + expect(selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.targetMissing + }) + }) + + it('orphans a workspace that resolves to more than one host instead of assuming Self', () => { + const selector = projectAutomationSelector( + automation({ workspaceId: 'folder:fw-1' }), + context({ workspaceHost: () => ({ kind: 'ambiguous' }) }) + ) + + expect(selector).toEqual({ + kind: 'orphan', + issue: AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous + }) + }) + + it('leaves the repo in charge when nothing pins the workspace', () => { + expect( + projectAutomationSelector( + automation(), + context({ workspaceHost: () => ({ kind: 'unpinned' }) }) + ) + ).toEqual({ kind: 'self' }) + expect( + projectAutomationSelector( + automation({ + projectId: 'repo-ssh', + runContext: { repoId: 'repo-ssh' } + } as Partial), + context({ workspaceHost: () => ({ kind: 'local' }) }) + ) + ).toEqual({ kind: 'orphan', issue: AUTOMATION_ORPHAN_ISSUES.malformed }) + }) +}) diff --git a/src/shared/automation-list-scope.ts b/src/shared/automation-list-scope.ts new file mode 100644 index 00000000000..6942f2beab6 --- /dev/null +++ b/src/shared/automation-list-scope.ts @@ -0,0 +1,289 @@ +/** + * The `automation.list` request/response contract. + * + * The request is host-scoped inside one authority: the authority itself is the + * IPC endpoint or the paired RPC connection and is never a caller-chosen field. + * An omitted selector is the legacy request and still answers with the + * authority's complete list, so an old client reading a new host is unaffected. + * + * The response keeps `automations` for those old clients and adds `items`, + * which qualifies each row with the selector that actually produced it. Rows are + * paired by ID rather than nested so the legacy field is not duplicated on the + * wire. + */ + +import type { Automation } from './automations-types' +import type { AutomationUsageSummary } from './automation-usage-summary' +import { getAutomationRunRepoId } from './automation-run-identity' +import { parseExecutionHostId } from './execution-host' +import { sanitizeSshTargetGeneration } from './ssh-target-generation' +import { isWorkspaceSshPinRepinned } from './automation-workspace-repin' + +export type AutomationListScopeSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; expectedTargetGeneration: number } + | { kind: 'orphan' } + +export type AutomationListParams = { + /** Omitted, like `null` or `{}`, requests the authority's complete list. */ + selector?: AutomationListScopeSelector +} + +export type AutomationListItemSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; targetGeneration: number } + | { kind: 'orphan'; issue: string } + +export type AutomationListItem = { + automationId: string + selector: AutomationListItemSelector + usageSummary?: AutomationUsageSummary | null +} + +export type AutomationListResult = { + automations: Automation[] + items: AutomationListItem[] + /** + * Orphans across the whole authority, so any scoped answer can reveal the + * orphan bucket. Optional on the wire, and absent must stay absent: a zero + * here settles a bucket nobody counted. + */ + orphanCount?: number +} + +/** What a host with no scoped list support returns, and all an old client reads. */ +export type LegacyAutomationListResult = { automations: Automation[] } + +export const AUTOMATION_ORPHAN_ISSUES = { + targetMissing: 'Its SSH host is no longer registered.', + targetReplaced: 'Its SSH host was removed and re-added, so it must be re-adopted.', + scheduledElsewhere: 'It is stored here but scheduled against a remote runtime.', + projectMissing: 'Its project no longer exists, so its host cannot be resolved.', + projectUnverified: 'Its project could not be checked from here, so its host is unconfirmed.', + workspaceHostAmbiguous: 'Its workspace spans more than one host, so it has no single owner.', + malformed: 'Its stored host record is incomplete.' +} as const + +/** Where a record's workspace executes; `unpinned` leaves the repo to decide. */ +export type AutomationWorkspaceHost = + | { kind: 'unpinned' } + | { kind: 'local' } + | { kind: 'ssh'; targetId: string } + | { kind: 'ambiguous' } + +export type AutomationProjectionContext = { + /** Current registration generation of a saved SSH target; undefined once it is gone. */ + sshTargetGeneration: (targetId: string) => number | undefined + /** `undefined` = the repo is gone; `null` = a local repo; a string = its SSH connection. */ + repoConnectionId: (repoId: string) => string | null | undefined + /** Host the record's workspace pins it to; omitted means no side knows of a pin. */ + workspaceHost?: (automation: Automation) => AutomationWorkspaceHost + /** Registration a generation was allocated to; omitted leaves a re-pin unproven. */ + sshTargetIdForGeneration?: (generation: number) => string | undefined + usageSummary?: (automationId: string) => AutomationUsageSummary | null +} + +function orphan(issue: string): AutomationListItemSelector { + return { kind: 'orphan', issue } +} + +function sshSelector( + automation: Automation, + context: AutomationProjectionContext +): AutomationListItemSelector { + const targetId = automation.executionTargetId?.trim() + if (!targetId) { + return orphan(AUTOMATION_ORPHAN_ISSUES.malformed) + } + return sshTargetSelector(targetId, automation.executionTargetGeneration, context) +} + +function sshTargetSelector( + targetId: string, + capturedGeneration: number | undefined, + context: AutomationProjectionContext +): AutomationListItemSelector { + const current = context.sshTargetGeneration(targetId) + if (current === undefined) { + return orphan(AUTOMATION_ORPHAN_ISSUES.targetMissing) + } + const captured = sanitizeSshTargetGeneration(capturedGeneration) + if (captured !== undefined && captured !== current) { + return orphan(AUTOMATION_ORPHAN_ISSUES.targetReplaced) + } + return { kind: 'ssh', targetId, targetGeneration: captured ?? current } +} + +/** + * The pin decides the host; the capture only decides whether this incarnation of + * it is the one the record was attached to. A capture a different live + * registration carries came from the pin the workspace has since moved off, so + * it says nothing about this one and is dropped rather than read as a mismatch. + */ +function workspacePinSelector( + automation: Automation, + targetId: string, + context: AutomationProjectionContext +): AutomationListItemSelector { + const repinned = isWorkspaceSshPinRepinned({ + capturedGeneration: automation.executionTargetGeneration, + pin: { targetId, generation: context.sshTargetGeneration(targetId) }, + sshTargetIdForGeneration: context.sshTargetIdForGeneration + }) + return sshTargetSelector( + targetId, + repinned ? undefined : automation.executionTargetGeneration, + context + ) +} + +/** + * Storage authority is never inferred here — `schedulerOwner` and + * `runContext.hostId` only mark a record unrunnable, and a bare `local` is not + * evidence of Self without a resolvable local project behind it. + */ +export function projectAutomationSelector( + automation: Automation, + context: AutomationProjectionContext +): AutomationListItemSelector { + if ( + automation.schedulerOwner === 'remote_host_service' || + parseExecutionHostId(automation.runContext?.hostId)?.kind === 'runtime' + ) { + return orphan(AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere) + } + if (automation.executionTargetType === 'ssh') { + return sshSelector(automation, context) + } + if (automation.executionTargetType !== 'local') { + return orphan(AUTOMATION_ORPHAN_ISSUES.malformed) + } + const workspaceHost = context.workspaceHost?.(automation) ?? { kind: 'unpinned' } + if (workspaceHost.kind === 'ambiguous') { + return orphan(AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous) + } + // A pinned workspace is where the run actually goes, so the row belongs to that host, not Self. + if (workspaceHost.kind === 'ssh') { + return workspacePinSelector(automation, workspaceHost.targetId, context) + } + const connectionId = context.repoConnectionId(getAutomationRunRepoId(automation)) + if (connectionId === undefined) { + return orphan(AUTOMATION_ORPHAN_ISSUES.projectMissing) + } + // A local record whose project points at an SSH connection contradicts itself. + return connectionId ? orphan(AUTOMATION_ORPHAN_ISSUES.malformed) : { kind: 'self' } +} + +/** The orphan diagnoses that answer "does this record still have one usable host?" */ +export type AutomationCapturedHostIssue = + | typeof AUTOMATION_ORPHAN_ISSUES.targetMissing + | typeof AUTOMATION_ORPHAN_ISSUES.targetReplaced + | typeof AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous + +// Keyed by the union, not a list of it: a list stays valid when the union grows, so a +// fourth diagnosis would compile while never reaching dispatch. This makes omitting it here +// the compile error, not just omitting its message. +const CAPTURED_HOST_ISSUES: Record = { + [AUTOMATION_ORPHAN_ISSUES.targetMissing]: true, + [AUTOMATION_ORPHAN_ISSUES.targetReplaced]: true, + // No single owner is as unrunnable as no owner: dispatching would pick a host the user never chose. + [AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous]: true +} + +function isCapturedHostIssue(issue: string): issue is AutomationCapturedHostIssue { + return Object.prototype.hasOwnProperty.call(CAPTURED_HOST_ISSUES, issue) +} + +/** + * Which host-identity diagnosis stands against the record, if any. + * + * Dispatch reads the list's verdict instead of re-deriving one, so the two can + * never disagree about the host a record belongs to. The diagnosis is returned + * rather than a yes/no because callers must keep the two apart: a gone host and + * a replaced one need different answers for the user, and run coalescing folds + * only on identical text. The other orphan reasons are different questions and + * stay with whoever already answers them — notably `scheduledElsewhere`, which + * is what a remote server's own scheduler is there to run. + */ +export function automationCapturedHostIssue( + automation: Automation, + context: AutomationProjectionContext +): AutomationCapturedHostIssue | null { + const selector = projectAutomationSelector(automation, context) + if (selector.kind !== 'orphan') { + return null + } + return isCapturedHostIssue(selector.issue) ? selector.issue : null +} + +export function automationSelectorMatchesScope( + selector: AutomationListItemSelector, + scope: AutomationListScopeSelector +): boolean { + if (scope.kind === 'ssh') { + return ( + selector.kind === 'ssh' && + selector.targetId === scope.targetId && + selector.targetGeneration === scope.expectedTargetGeneration + ) + } + return selector.kind === scope.kind +} + +/** Projects every stored automation, then narrows to the requested scope. */ +export function projectAutomationList( + automations: readonly Automation[], + context: AutomationProjectionContext, + scope?: AutomationListScopeSelector +): AutomationListResult { + const projected = automations.map((automation) => ({ + automation, + selector: projectAutomationSelector(automation, context) + })) + const orphanCount = projected.filter((entry) => entry.selector.kind === 'orphan').length + const scoped = scope + ? projected.filter((entry) => automationSelectorMatchesScope(entry.selector, scope)) + : projected + return { + automations: scoped.map((entry) => entry.automation), + items: scoped.map((entry) => ({ + automationId: entry.automation.id, + selector: entry.selector, + ...(context.usageSummary ? { usageSummary: context.usageSummary(entry.automation.id) } : {}) + })), + orphanCount + } +} + +/** What a scoped `automationsChanged` event names; carries no incarnation, only the stable host. */ +export type AutomationChangeSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string } + | { kind: 'orphan' } + +export function toAutomationChangeSelector( + selector: AutomationListItemSelector +): AutomationChangeSelector { + return selector.kind === 'ssh' + ? { kind: 'ssh', targetId: selector.targetId } + : { kind: selector.kind } +} + +/** + * The selectors a mutation must publish. A move publishes both source and + * destination; an unrecoverable side degrades the whole publication to one + * unscoped authority event, because a subscriber that never hears about the + * entry it is showing would keep rendering a row that no longer exists. + */ +export function automationChangePublications( + before: AutomationChangeSelector | null, + after: AutomationChangeSelector | null +): (AutomationChangeSelector | undefined)[] { + if (!before || !after) { + return [undefined] + } + const sameHost = + before.kind === after.kind && + (before.kind !== 'ssh' || after.kind !== 'ssh' || before.targetId === after.targetId) + return sameHost ? [after] : [before, after] +} diff --git a/src/shared/automation-owner-conflict.ts b/src/shared/automation-owner-conflict.ts new file mode 100644 index 00000000000..4c3b358ca49 --- /dev/null +++ b/src/shared/automation-owner-conflict.ts @@ -0,0 +1,88 @@ +/** + * The typed conflicts an authority returns when a request's captured owner no + * longer matches its stored automation. + * + * These cross two transports that keep nothing but a string: Electron IPC + * rewraps the message ("Error invoking remote method 'x': Error: …") and an + * older runtime maps unknown error classes to `runtime_error`. So every message + * ends with `: `, the machine-token convention `hasRuntimeRpcErrorCode` + * already matches, and callers strip the tail before display. + */ + +import { hasRuntimeRpcErrorCode } from './runtime-rpc-error-code' + +export const AUTOMATION_OWNER_CONFLICT_CODES = { + /** The stored selector or its SSH generation differs from the captured one. */ + ownerChanged: 'automation_owner_changed', + /** The record's SSH target is no longer registered, so nothing may execute on it. */ + targetRemoved: 'automation_target_removed', + /** The caller omitted a precondition for a record that carries a fenceable SSH owner. */ + fencingRequired: 'automation_owner_fencing_required', + /** The requested create/update destination does not resolve on this authority. */ + invalidDestination: 'automation_destination_invalid' +} as const + +export type AutomationOwnerConflictCode = + (typeof AUTOMATION_OWNER_CONFLICT_CODES)[keyof typeof AUTOMATION_OWNER_CONFLICT_CODES] + +const CONFLICT_MESSAGES: Record = { + [AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged]: + "This automation's host changed. Reload it before continuing.", + [AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved]: + "This automation's SSH host was removed, so it cannot run. Delete it or re-add that host.", + [AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired]: + 'This automation is pinned to an SSH host, so the request must name the host it expects.', + [AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination]: + 'That host is no longer available on this authority. Pick a host and try again.' +} + +export function automationOwnerConflictMessage(code: AutomationOwnerConflictCode): string { + return CONFLICT_MESSAGES[code] +} + +export class AutomationOwnerConflictError extends Error { + readonly code: AutomationOwnerConflictCode + + constructor(code: AutomationOwnerConflictCode) { + super(`${CONFLICT_MESSAGES[code]}: ${code}`) + this.name = 'AutomationOwnerConflictError' + this.code = code + } +} + +const CODE_VALUES: readonly AutomationOwnerConflictCode[] = Object.values( + AUTOMATION_OWNER_CONFLICT_CODES +) + +export function isAutomationOwnerConflictCode( + value: unknown +): value is AutomationOwnerConflictCode { + return typeof value === 'string' && CODE_VALUES.includes(value as AutomationOwnerConflictCode) +} + +/** + * The conflict an authority returned, whatever the transport left of it. + * + * The single classifier for every client: matching `.code` alone reads only the + * hop that preserves the error class, and a flattened `runtime_error` would + * then lose the recovery the user needs. + */ +export function matchAutomationOwnerConflict(error: unknown): AutomationOwnerConflictCode | null { + for (const code of CODE_VALUES) { + if (hasRuntimeRpcErrorCode(error, code)) { + return code + } + } + return null +} + +/** Removes the trailing machine token so a conflict can be shown to a user verbatim. */ +export function stripAutomationOwnerConflictCode(message: string): string { + for (const code of CODE_VALUES) { + const suffix = `: ${code}` + if (message.trimEnd().endsWith(suffix)) { + return message.trimEnd().slice(0, -suffix.length) + } + } + return message +} diff --git a/src/shared/automation-owner-key.test.ts b/src/shared/automation-owner-key.test.ts new file mode 100644 index 00000000000..e81592f4385 --- /dev/null +++ b/src/shared/automation-owner-key.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + hostStableKey, + isSameAutomationOwner, + isSameStableAutomationHost, + ownerKey, + parseHostStableKey, + toStableCatalogRef +} from './automation-owner-key' +import type { AutomationOwnerRef, StableAutomationCatalogRef } from './automation-owner-ref' + +const desktopSelf: AutomationOwnerRef = { + authority: { kind: 'desktop' }, + selector: { kind: 'self' } +} + +const desktopSsh = (targetId: string, targetGeneration: number): AutomationOwnerRef => ({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId, targetGeneration } +}) + +const runtimeSsh = ( + environmentId: string, + pairingRevision: number, + targetId: string, + targetGeneration: number +): AutomationOwnerRef => ({ + authority: { kind: 'runtime', environmentId, pairingRevision }, + selector: { kind: 'ssh', targetId, targetGeneration } +}) + +describe('automation owner key injectivity', () => { + it('does not let a separator inside a component forge another key', () => { + // Naive concatenation collides here: both produce host:runtime:a:ssh:self. + const environmentIdCarriesSelector: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'a:ssh' }, + selector: { kind: 'self' } + } + const targetIdCarriesSelector: StableAutomationCatalogRef = { + authority: { kind: 'runtime', environmentId: 'a' }, + selector: { kind: 'ssh', targetId: 'self' } + } + expect(hostStableKey(environmentIdCarriesSelector)).not.toBe( + hostStableKey(targetIdCarriesSelector) + ) + }) + + it('does not let a pre-escaped component alias an unescaped one', () => { + expect( + hostStableKey({ + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 'a%3Ab' } + }) + ).not.toBe( + hostStableKey({ authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'a:b' } }) + ) + }) + + it('separates desktop from runtime for the same selector', () => { + expect(ownerKey(desktopSelf)).not.toBe( + ownerKey({ + authority: { kind: 'runtime', environmentId: 'e1', pairingRevision: 1 }, + selector: { kind: 'self' } + }) + ) + }) + + it('separates the same target id under desktop and under a runtime', () => { + expect(ownerKey(desktopSsh('t1', 4))).not.toBe(ownerKey(runtimeSsh('e1', 1, 't1', 4))) + }) + + it('keeps stable and owner keys in separate namespaces', () => { + expect(ownerKey(desktopSelf)).not.toBe(hostStableKey(toStableCatalogRef(desktopSelf))) + }) +}) + +describe('incarnation sensitivity', () => { + it('stable key ignores pairing revision and target generation', () => { + expect(hostStableKey(toStableCatalogRef(runtimeSsh('e1', 1, 't1', 4)))).toBe( + hostStableKey(toStableCatalogRef(runtimeSsh('e1', 9, 't1', 7))) + ) + expect( + isSameStableAutomationHost( + toStableCatalogRef(runtimeSsh('e1', 1, 't1', 4)), + toStableCatalogRef(runtimeSsh('e1', 9, 't1', 7)) + ) + ).toBe(true) + }) + + it('owner key does not ignore them', () => { + expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 1, 't1', 4))).toBe( + true + ) + expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 1, 't1', 5))).toBe( + false + ) + expect(isSameAutomationOwner(runtimeSsh('e1', 1, 't1', 4), runtimeSsh('e1', 2, 't1', 4))).toBe( + false + ) + }) + + it('drops incarnations when projecting an owner to a stable ref', () => { + expect(toStableCatalogRef(runtimeSsh('e1', 3, 't1', 4))).toEqual({ + authority: { kind: 'runtime', environmentId: 'e1' }, + selector: { kind: 'ssh', targetId: 't1' } + }) + }) +}) + +describe('parseHostStableKey', () => { + const refs: StableAutomationCatalogRef[] = [ + { authority: { kind: 'desktop' }, selector: { kind: 'self' } }, + { authority: { kind: 'desktop' }, selector: { kind: 'orphan' } }, + { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 'weird:id/with spaces' } }, + { authority: { kind: 'runtime', environmentId: 'env:1' }, selector: { kind: 'self' } }, + { authority: { kind: 'runtime', environmentId: 'env 1' }, selector: { kind: 'orphan' } }, + { authority: { kind: 'runtime', environmentId: 'e' }, selector: { kind: 'ssh', targetId: 't' } } + ] + + it('round-trips every catalog ref shape', () => { + for (const ref of refs) { + expect(parseHostStableKey(hostStableKey(ref))).toEqual(ref) + } + }) + + it.each([ + '', + 'host', + 'owner:desktop:self', + 'host:desktop', + 'host:desktop:ssh', + 'host:desktop:ssh:a:b', + 'host:desktop:bogus', + 'host:runtime:self', + 'host:runtime::self', + // non-canonical escaping must not alias the canonical key + 'host:desktop:ssh:a/b' + ])('rejects malformed key %s', (key) => { + expect(parseHostStableKey(key)).toBeNull() + }) +}) diff --git a/src/shared/automation-owner-key.ts b/src/shared/automation-owner-key.ts new file mode 100644 index 00000000000..24e1ae2b655 --- /dev/null +++ b/src/shared/automation-owner-key.ts @@ -0,0 +1,149 @@ +import type { + AutomationAuthorityRef, + AutomationHostSelector, + AutomationOwnerRef, + StableAutomationAuthorityRef, + StableAutomationCatalogRef, + StableAutomationCatalogSelector +} from './automation-owner-ref' + +/** + * The one canonical encoder for automation ownership keys. + * + * Every component is `encodeURIComponent`-escaped before joining, so the + * separator can never appear inside a component and a hostile target ID cannot + * forge another host's key. Display labels, bare IDs, and `JSON.stringify` are + * never keys: labels change, bare IDs collide across authorities, and object + * key order is not part of the language contract. + * + * Stable and owner keys live in separate namespaces (`host:` / `owner:`) so a + * desktop+self stable key can never be mistaken for a desktop+self owner key if + * the two ever share a map. + */ + +const SEPARATOR = ':' +const HOST_STABLE_NAMESPACE = 'host' +const OWNER_NAMESPACE = 'owner' + +function encodePart(value: string): string { + return encodeURIComponent(value) +} + +// Numbers are encoded through the same escaper so exotic literals ('1e+21') stay separator-free. +function encodeNumber(value: number): string { + return encodePart(String(value)) +} + +function decodePart(raw: string): string | null { + let decoded: string + try { + decoded = decodeURIComponent(raw) + } catch { + return null + } + // Why: require the canonical escaping so a non-round-tripping key is rejected rather than aliased. + return decoded && encodePart(decoded) === raw ? decoded : null +} + +function stableAuthorityParts(authority: StableAutomationAuthorityRef): string[] { + return authority.kind === 'desktop' + ? ['desktop'] + : ['runtime', encodePart(authority.environmentId)] +} + +function ownerAuthorityParts(authority: AutomationAuthorityRef): string[] { + return authority.kind === 'desktop' + ? ['desktop'] + : ['runtime', encodePart(authority.environmentId), encodeNumber(authority.pairingRevision)] +} + +function stableSelectorParts(selector: StableAutomationCatalogSelector): string[] { + return selector.kind === 'ssh' ? ['ssh', encodePart(selector.targetId)] : [selector.kind] +} + +function ownerSelectorParts(selector: AutomationHostSelector): string[] { + return selector.kind === 'ssh' + ? ['ssh', encodePart(selector.targetId), encodeNumber(selector.targetGeneration)] + : ['self'] +} + +/** Key for the persisted filter and display slot; excludes every incarnation field. */ +export function hostStableKey(ref: StableAutomationCatalogRef): string { + return [ + HOST_STABLE_NAMESPACE, + ...stableAuthorityParts(ref.authority), + ...stableSelectorParts(ref.selector) + ].join(SEPARATOR) +} + +/** Key for fetched rows, mutations, secondary reads, and commit fences; includes incarnations. */ +export function ownerKey(owner: AutomationOwnerRef): string { + return [ + OWNER_NAMESPACE, + ...ownerAuthorityParts(owner.authority), + ...ownerSelectorParts(owner.selector) + ].join(SEPARATOR) +} + +export function toStableCatalogRef(owner: AutomationOwnerRef): StableAutomationCatalogRef { + const authority: StableAutomationAuthorityRef = + owner.authority.kind === 'desktop' + ? { kind: 'desktop' } + : { kind: 'runtime', environmentId: owner.authority.environmentId } + return owner.selector.kind === 'ssh' + ? { authority, selector: { kind: 'ssh', targetId: owner.selector.targetId } } + : { authority, selector: { kind: 'self' } } +} + +export function isSameAutomationOwner(a: AutomationOwnerRef, b: AutomationOwnerRef): boolean { + return ownerKey(a) === ownerKey(b) +} + +export function isSameStableAutomationHost( + a: StableAutomationCatalogRef, + b: StableAutomationCatalogRef +): boolean { + return hostStableKey(a) === hostStableKey(b) +} + +function parseStableAuthority( + parts: readonly string[] +): { authority: StableAutomationAuthorityRef; rest: readonly string[] } | null { + if (parts[0] === 'desktop') { + return { authority: { kind: 'desktop' }, rest: parts.slice(1) } + } + if (parts[0] !== 'runtime') { + return null + } + const environmentId = parts[1] === undefined ? null : decodePart(parts[1]) + return environmentId + ? { authority: { kind: 'runtime', environmentId }, rest: parts.slice(2) } + : null +} + +function parseStableSelector(parts: readonly string[]): StableAutomationCatalogSelector | null { + if (parts.length === 1 && (parts[0] === 'self' || parts[0] === 'orphan')) { + return { kind: parts[0] } + } + if (parts.length !== 2 || parts[0] !== 'ssh') { + return null + } + const targetId = decodePart(parts[1]) + return targetId ? { kind: 'ssh', targetId } : null +} + +/** Inverse of {@link hostStableKey}; returns null for anything malformed. */ +export function parseHostStableKey(key: string): StableAutomationCatalogRef | null { + const parts = key.split(SEPARATOR) + if (parts[0] !== HOST_STABLE_NAMESPACE) { + return null + } + const parsedAuthority = parseStableAuthority(parts.slice(1)) + if (!parsedAuthority) { + return null + } + const selector = parseStableSelector(parsedAuthority.rest) + return selector + ? ({ authority: parsedAuthority.authority, selector } as StableAutomationCatalogRef) + : null +} diff --git a/src/shared/automation-owner-precondition.test.ts b/src/shared/automation-owner-precondition.test.ts new file mode 100644 index 00000000000..3ac6364810c --- /dev/null +++ b/src/shared/automation-owner-precondition.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from './automations-types' +import type { AutomationProjectionContext } from './automation-list-scope' +import { AUTOMATION_OWNER_CONFLICT_CODES } from './automation-owner-conflict' +import { + assertAutomationDestination, + assertAutomationOwnerFence, + assertExecutionTargetMatchesDestination +} from './automation-owner-precondition' + +function automation(overrides: Partial = {}): Automation { + return { + id: 'a1', + name: 'Nightly', + prompt: 'go', + precheck: null, + agentId: 'claude', + 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: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } as Automation +} + +const context: AutomationProjectionContext = { + sshTargetGeneration: (targetId) => (targetId === 'ssh-1' ? 7 : undefined), + repoConnectionId: (repoId) => + repoId === 'repo-1' ? null : repoId === 'repo-ssh' ? 'ssh-1' : undefined +} + +const sshRecord = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7, + projectId: 'repo-ssh' +}) + +/** A `local` record its folder workspace pins to SSH: SSH-owned by projection, not by storage. */ +const pinnedLocalRecord = automation({ workspaceId: 'folder:fw-1' }) +const pinnedContext: AutomationProjectionContext = { + ...context, + workspaceHost: () => ({ kind: 'ssh', targetId: 'ssh-1' }) +} + +function expectConflict(run: () => void, code: string): void { + expect(run).toThrowError(expect.objectContaining({ code })) +} + +describe('assertAutomationOwnerFence', () => { + it('passes when the captured owner still matches', () => { + expect(() => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } + }, + operation: 'mutate', + context + }) + ).not.toThrow() + }) + + it('fails closed on a stale SSH generation', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { + selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 6 } + }, + operation: 'mutate', + context + }), + AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + ) + }) + + it('fails closed when the expected host is a different target', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { + selector: { kind: 'ssh', targetId: 'ssh-2', targetGeneration: 7 } + }, + operation: 'mutate', + context + }), + AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + ) + }) + + it('requires a precondition before mutating a record pinned to a generation', () => { + expectConflict( + () => assertAutomationOwnerFence({ automation: sshRecord, operation: 'mutate', context }), + AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired + ) + }) + + it('still lets an old client read that record', () => { + expect(() => + assertAutomationOwnerFence({ automation: sshRecord, operation: 'read', context }) + ).not.toThrow() + }) + + it('leaves a legacy client unaffected on self records', () => { + expect(() => + assertAutomationOwnerFence({ automation: automation(), operation: 'mutate', context }) + ).not.toThrow() + }) + + // The stored field is absent on a workspace-pinned record while the projection carries a + // generation, so keying the demand off storage let precisely this population write unfenced. + it('requires a precondition on a record the projection presents as SSH-owned', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + operation: 'mutate', + context: pinnedContext + }), + AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired + ) + expectConflict( + () => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + operation: 'execute', + context: pinnedContext + }), + AUTOMATION_OWNER_CONFLICT_CODES.fencingRequired + ) + }) + + it('still lets an old client read a workspace-pinned record', () => { + expect(() => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + operation: 'read', + context: pinnedContext + }) + ).not.toThrow() + }) + + it('accepts the projected owner as the precondition for a workspace-pinned record', () => { + expect(() => + assertAutomationOwnerFence({ + automation: pinnedLocalRecord, + expectedOwner: { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + operation: 'mutate', + context: pinnedContext + }) + ).not.toThrow() + }) + + it('rejects a self precondition against an SSH record', () => { + expectConflict( + () => + assertAutomationOwnerFence({ + automation: sshRecord, + expectedOwner: { selector: { kind: 'self' } }, + operation: 'mutate', + context + }), + AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged + ) + }) + + it('keeps orphan delete available but refuses to dispatch it', () => { + const orphan = automation({ + executionTargetType: 'ssh', + executionTargetId: 'ssh-gone', + executionTargetGeneration: 4 + }) + expect(() => + assertAutomationOwnerFence({ + automation: orphan, + expectedOwner: { selector: { kind: 'orphan' } }, + operation: 'mutate', + context + }) + ).not.toThrow() + expect(() => + assertAutomationOwnerFence({ automation: orphan, operation: 'mutate', context }) + ).not.toThrow() + expectConflict( + () => assertAutomationOwnerFence({ automation: orphan, operation: 'execute', context }), + AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved + ) + }) +}) + +describe('destination validation', () => { + it('accepts an SSH destination whose registration still matches', () => { + expect(() => + assertAutomationDestination( + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + context + ) + ).not.toThrow() + }) + + it('rejects a ghost or future same-id target', () => { + expectConflict( + () => + assertAutomationDestination( + { selector: { kind: 'ssh', targetId: 'ssh-gone', targetGeneration: 1 } }, + context + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + expectConflict( + () => + assertAutomationDestination( + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 8 } }, + context + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + }) + + it('rejects a record that would land on a different host than the caller chose', () => { + expectConflict( + () => + assertExecutionTargetMatchesDestination( + { executionTargetType: 'local', executionTargetId: 'local' }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + expect(() => + assertExecutionTargetMatchesDestination( + { + executionTargetType: 'ssh', + executionTargetId: 'ssh-1', + executionTargetGeneration: 7 + }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } } + ) + ).not.toThrow() + }) + + // The stored fields say `local`, but the pin is where the run goes: reading storage let a + // record dispatch sends over SSH land under a Self destination the user chose. + it('refuses a self destination for a record its workspace pins to SSH', () => { + expectConflict( + () => + assertExecutionTargetMatchesDestination( + { + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 7 + }, + { selector: { kind: 'self' } }, + { targetId: 'ssh-1', generation: 7 } + ), + AUTOMATION_OWNER_CONFLICT_CODES.invalidDestination + ) + }) + + it('accepts the pinned host as the destination for that same record', () => { + expect(() => + assertExecutionTargetMatchesDestination( + { + executionTargetType: 'local', + executionTargetId: 'local', + executionTargetGeneration: 7 + }, + { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }, + { targetId: 'ssh-1', generation: 7 } + ) + ).not.toThrow() + }) + + it('leaves an unpinned local record landing on self', () => { + expect(() => + assertExecutionTargetMatchesDestination( + { executionTargetType: 'local', executionTargetId: 'local' }, + { selector: { kind: 'self' } } + ) + ).not.toThrow() + }) +}) diff --git a/src/shared/automation-owner-precondition.ts b/src/shared/automation-owner-precondition.ts new file mode 100644 index 00000000000..4d284b98fb5 --- /dev/null +++ b/src/shared/automation-owner-precondition.ts @@ -0,0 +1,167 @@ +/** + * The owner precondition an authority enforces before it reads, mutates, or + * executes one stored automation. + * + * Optional on the wire is not unenforced: a record the projection owns by an SSH + * registration refuses a mutation that names no expected owner, so a client that + * skips the field cannot make unfenced writes permanent server behavior. The + * precondition stays genuinely optional only where nothing can be fenced — Self + * records and orphans. Legacy SSH rows are not a carve-out: the load-time owner + * migration stamps every one whose target still resolves, so a survivor without + * a capture projects as an orphan rather than as an unfenced SSH owner. + */ + +import type { Automation } from './automations-types' +import type { AutomationExecutionTargetSelection } from './automation-execution-target' +import type { AutomationWorkspaceSshPin } from './automation-workspace-pin' +import { + projectAutomationSelector, + type AutomationListItemSelector, + type AutomationProjectionContext +} from './automation-list-scope' +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from './automation-owner-conflict' + +export type AutomationOwnerPreconditionSelector = + | { kind: 'self' } + | { kind: 'ssh'; targetId: string; targetGeneration: number } + | { kind: 'orphan' } + +export type AutomationOwnerPrecondition = { + selector: AutomationOwnerPreconditionSelector +} + +/** Where a create or a selector-moving update wants the record to land. */ +export type AutomationDestination = { + selector: { kind: 'self' } | { kind: 'ssh'; targetId: string; targetGeneration: number } +} + +export type AutomationOwnerFenceOperation = 'read' | 'mutate' | 'execute' + +/** + * The precondition a client echoes back to act on a record it just read. + * + * The orphan issue is dropped on purpose: a precondition names the host the + * caller believes owns the record, not the reason that host is unusable. + */ +export function toAutomationOwnerPrecondition( + selector: AutomationListItemSelector +): AutomationOwnerPrecondition { + return { + selector: + selector.kind === 'ssh' + ? { + kind: 'ssh', + targetId: selector.targetId, + targetGeneration: selector.targetGeneration + } + : { kind: selector.kind } + } +} + +export type AutomationOwnerFenceInput = { + automation: Automation + expectedOwner?: AutomationOwnerPrecondition + operation: AutomationOwnerFenceOperation + context: AutomationProjectionContext +} + +function conflict(code: keyof typeof AUTOMATION_OWNER_CONFLICT_CODES): never { + throw new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES[code]) +} + +export function assertAutomationOwnerFence(input: AutomationOwnerFenceInput): void { + const stored = projectAutomationSelector(input.automation, input.context) + const expected = input.expectedOwner?.selector + if (stored.kind === 'orphan' && input.operation === 'execute') { + conflict('targetRemoved') + } + if (!expected) { + // The projected selector, not the stored field, says what is fenceable: a workspace-pinned + // record is SSH-owned with the capture on its pin, so keying off storage let exactly that + // population write unfenced. An SSH projection always carries a generation. + if (stored.kind === 'ssh' && input.operation !== 'read') { + conflict('fencingRequired') + } + return + } + if (expected.kind !== stored.kind) { + conflict('ownerChanged') + } + if ( + expected.kind === 'ssh' && + stored.kind === 'ssh' && + (expected.targetId !== stored.targetId || expected.targetGeneration !== stored.targetGeneration) + ) { + conflict('ownerChanged') + } +} + +/** A destination is resolved against the saved target registry, never accepted as a free-form value. */ +export function assertAutomationDestination( + destination: AutomationDestination, + context: Pick +): void { + const selector = destination.selector + if (selector.kind === 'self') { + return + } + if (selector.kind !== 'ssh' || !selector.targetId) { + conflict('invalidDestination') + } + if (context.sshTargetGeneration(selector.targetId) !== selector.targetGeneration) { + conflict('invalidDestination') + } +} + +/** + * Where the derived selection actually lands, which is the projected selector + * rather than the stored fields: a `local` target under a workspace pin runs on + * that registration, so the stored type is not the host. + */ +function landingSelector( + selection: AutomationExecutionTargetSelection, + workspaceSshPin: AutomationWorkspaceSshPin | undefined +): { kind: 'self' } | { kind: 'ssh'; targetId: string; targetGeneration: number | undefined } { + if (selection.executionTargetType === 'ssh') { + return { + kind: 'ssh', + targetId: selection.executionTargetId, + targetGeneration: selection.executionTargetGeneration + } + } + if (selection.executionTargetType === 'local' && workspaceSshPin) { + return { + kind: 'ssh', + targetId: workspaceSshPin.targetId, + targetGeneration: selection.executionTargetGeneration ?? workspaceSshPin.generation + } + } + return { kind: 'self' } +} + +/** The host the record actually lands on must be the one the caller chose. */ +export function assertExecutionTargetMatchesDestination( + selection: AutomationExecutionTargetSelection, + destination: AutomationDestination, + /** The SSH target the record's workspace pins it to once the write lands. */ + workspaceSshPin?: AutomationWorkspaceSshPin +): void { + const landing = landingSelector(selection, workspaceSshPin) + const selector = destination.selector + if (selector.kind === 'self') { + if (landing.kind !== 'self') { + conflict('invalidDestination') + } + return + } + if ( + landing.kind !== 'ssh' || + landing.targetId !== selector.targetId || + (landing.targetGeneration ?? selector.targetGeneration) !== selector.targetGeneration + ) { + conflict('invalidDestination') + } +} diff --git a/src/shared/automation-owner-ref.ts b/src/shared/automation-owner-ref.ts new file mode 100644 index 00000000000..ce8bc34ed0c --- /dev/null +++ b/src/shared/automation-owner-ref.ts @@ -0,0 +1,58 @@ +/** + * Canonical ownership identity for automation records. + * + * An automation ID is unique only inside the authority that stores it, so every + * row, request, and action carries the authority (desktop or a paired runtime) + * plus the host selector (self or one SSH target) that produced it. + * + * Two forms exist on purpose: + * - Stable refs survive renames and connection changes; they key the persisted + * host filter and the display slot. + * - Owner refs additionally pin the *incarnation* (`pairingRevision` for a + * runtime, `targetGeneration` for an SSH registration) so data captured from + * an old incarnation can never commit against a new one. + */ + +export type AutomationAuthorityRef = + | { kind: 'desktop' } + | { + kind: 'runtime' + environmentId: string + /** The saved runtime environment's pairing revision; a re-pair invalidates captured data. */ + pairingRevision: number + } + +export type AutomationHostSelector = + | { kind: 'self' } + | { + kind: 'ssh' + targetId: string + /** Durable SSH *registration* incarnation — not a connection attempt counter. */ + targetGeneration: number + } + +export type AutomationOwnerRef = { + authority: AutomationAuthorityRef + selector: AutomationHostSelector +} + +export type StableAutomationAuthorityRef = + | { kind: 'desktop' } + | { kind: 'runtime'; environmentId: string } + +export type StableAutomationHostSelector = { kind: 'self' } | { kind: 'ssh'; targetId: string } + +export type StableAutomationHostRef = { + authority: StableAutomationAuthorityRef + selector: StableAutomationHostSelector +} + +/** A stable host, or the per-authority bucket for records with no executable owner. */ +export type StableAutomationCatalogRef = + | StableAutomationHostRef + | { + authority: StableAutomationAuthorityRef + selector: { kind: 'orphan' } + } + +export type StableAutomationCatalogSelector = StableAutomationCatalogRef['selector'] diff --git a/src/shared/automation-usage-summary.test.ts b/src/shared/automation-usage-summary.test.ts new file mode 100644 index 00000000000..cc67d82328f --- /dev/null +++ b/src/shared/automation-usage-summary.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun, AutomationRunUsage } from './automations-types' +import { summarizeAutomationRunUsage } from './automation-usage-summary' + +const makeUsage = (overrides: Partial = {}): AutomationRunUsage => ({ + status: 'known', + provider: 'claude', + model: 'sonnet', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 3, + cacheWriteTokens: 2, + reasoningOutputTokens: 1, + totalTokens: 21, + estimatedCostUsd: 0.25, + estimatedCostSource: 'api_equivalent', + providerSessionId: 'session-1', + attribution: 'provider_session_time_window', + collectedAt: 1, + unavailableReason: null, + unavailableMessage: null, + ...overrides +}) + +const makeRun = (usage: AutomationRunUsage | null): AutomationRun => + ({ id: 'run-1', automationId: 'a1', usage }) as AutomationRun + +describe('summarizeAutomationRunUsage', () => { + it('aggregates known runs and counts the rest as unavailable', () => { + const summary = summarizeAutomationRunUsage([ + makeRun(makeUsage()), + makeRun(makeUsage({ estimatedCostUsd: null })), + makeRun(makeUsage({ status: 'unavailable' })), + makeRun(null) + ]) + + expect(summary).toEqual({ + knownRuns: 2, + unavailableRuns: 2, + inputTokens: 20, + outputTokens: 10, + cacheTokens: 10, + reasoningOutputTokens: 2, + totalTokens: 42, + estimatedCostUsd: 0.25 + }) + }) + + it('reports an unknown cost rather than zero when no run has one', () => { + const summary = summarizeAutomationRunUsage([makeRun(makeUsage({ estimatedCostUsd: null }))]) + expect(summary.estimatedCostUsd).toBeNull() + expect(summary.knownRuns).toBe(1) + }) + + it('summarizes an empty retained history without inventing usage', () => { + expect(summarizeAutomationRunUsage([])).toEqual({ + knownRuns: 0, + unavailableRuns: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + }) + }) +}) diff --git a/src/shared/automation-usage-summary.ts b/src/shared/automation-usage-summary.ts new file mode 100644 index 00000000000..adf7094d442 --- /dev/null +++ b/src/shared/automation-usage-summary.ts @@ -0,0 +1,56 @@ +import type { AutomationRun } from './automations-types' + +export type AutomationUsageSummary = { + knownRuns: number + unavailableRuns: number + inputTokens: number + outputTokens: number + cacheTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +/** Bounded aggregate over an authority's retained runs; never fetches history. */ +export function summarizeAutomationRunUsage( + runs: readonly AutomationRun[] +): AutomationUsageSummary { + let knownRuns = 0 + let unavailableRuns = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let estimatedCostUsd = 0 + let hasKnownCost = false + + for (const run of runs) { + const usage = run.usage + if (!usage || usage.status !== 'known') { + unavailableRuns++ + continue + } + knownRuns++ + inputTokens += usage.inputTokens ?? 0 + outputTokens += usage.outputTokens ?? 0 + cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + reasoningOutputTokens += usage.reasoningOutputTokens ?? 0 + totalTokens += usage.totalTokens ?? 0 + if (usage.estimatedCostUsd !== null) { + estimatedCostUsd += usage.estimatedCostUsd + hasKnownCost = true + } + } + + return { + knownRuns, + unavailableRuns, + inputTokens, + outputTokens, + cacheTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null + } +} diff --git a/src/shared/automation-workspace-pin.ts b/src/shared/automation-workspace-pin.ts new file mode 100644 index 00000000000..91cbebef42a --- /dev/null +++ b/src/shared/automation-workspace-pin.ts @@ -0,0 +1,42 @@ +/** + * The SSH target a stored automation's workspace pins it to. + * + * A folder workspace can execute on an SSH host while the automation's project + * is local, and the pin is where the run actually goes — so it, not the project, + * decides that record's host. The list projection, the create/update capture and + * the load-time backfill all have to agree on that answer, so it is resolved + * here once rather than re-derived per caller. + */ + +import type { Automation } from './automations-types' +import type { AutomationWorkspaceHost } from './automation-list-scope' +import { + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from './folder-workspace-execution-host' +import { parseWorkspaceKey } from './workspace-scope' + +/** A pinned SSH target plus the generation its registration currently carries. */ +export type AutomationWorkspaceSshPin = { targetId: string; generation: number | undefined } + +export function resolveAutomationWorkspaceHost( + state: FolderWorkspaceHostState, + workspaceId: Automation['workspaceId'] +): AutomationWorkspaceHost { + const scope = parseWorkspaceKey(workspaceId ?? '') + if (scope?.type !== 'folder') { + return { kind: 'unpinned' } + } + const host = resolveFolderWorkspaceHost(state, scope.folderWorkspaceId) + // A workspace that is gone proves nothing about the host, so the repo still decides. + return host.kind === 'missing' ? { kind: 'unpinned' } : host +} + +/** The pinned SSH target id, if the workspace names one. */ +export function resolveAutomationWorkspaceSshTargetId( + state: FolderWorkspaceHostState, + workspaceId: Automation['workspaceId'] +): string | undefined { + const host = resolveAutomationWorkspaceHost(state, workspaceId) + return host.kind === 'ssh' ? host.targetId : undefined +} diff --git a/src/shared/automation-workspace-repin.ts b/src/shared/automation-workspace-repin.ts new file mode 100644 index 00000000000..e53eed1e536 --- /dev/null +++ b/src/shared/automation-workspace-repin.ts @@ -0,0 +1,37 @@ +/** + * Evidence that a folder workspace was re-pointed at a different SSH host. + * + * A workspace-pinned record captures only a generation, so a capture that + * disagrees with the pin's current registration is ambiguous on its face: the + * workspace may have moved to another host, or the pinned host may have been + * removed and re-added under the same id. Those need opposite answers — follow + * the pin, or stay fenced — so the divergence is resolved from evidence rather + * than guessed. + * + * Generations come from one counter and are never reissued, so at most one + * registration ever carries a given generation. A *live* registration carrying + * the capture is therefore proof of which pin the record was attached to, and + * proof it is not this one. No live carrier proves nothing, and the positive- + * evidence rule leaves the capture alone there: an unfenced re-adopt would hand + * the schedule to whatever machine now answers to the pinned id. + */ + +import type { AutomationWorkspaceSshPin } from './automation-workspace-pin' +import { sanitizeSshTargetGeneration } from './ssh-target-generation' + +/** The registration a generation was allocated to, if one still carries it. */ +export type SshTargetIdForGeneration = (generation: number) => string | undefined + +export function isWorkspaceSshPinRepinned(input: { + capturedGeneration: number | undefined + pin: AutomationWorkspaceSshPin | undefined + sshTargetIdForGeneration: SshTargetIdForGeneration | undefined +}): boolean { + const captured = sanitizeSshTargetGeneration(input.capturedGeneration) + const pin = input.pin + if (captured === undefined || pin?.generation === undefined || captured === pin.generation) { + return false + } + const capturedTargetId = input.sshTargetIdForGeneration?.(captured) + return capturedTargetId !== undefined && capturedTargetId !== pin.targetId +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 82ebcb2dbae..16add3646ac 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -6,6 +6,7 @@ export type AutomationWorkspaceMode = 'existing' | 'new_per_run' export type AutomationExecutionTargetType = 'local' | 'ssh' export type AutomationSchedulerOwner = 'local_host_service' | 'ssh_bridge' | 'remote_host_service' export type AutomationMissedRunPolicy = 'run_once_within_grace' +export type AutomationEnablementDecider = 'owner_migration' | 'user' export type AutomationRunStatus = | 'pending' | 'dispatching' @@ -108,6 +109,10 @@ export type Automation = { projectId: string executionTargetType: AutomationExecutionTargetType executionTargetId: string + /** Why: pins the SSH registration incarnation this record was attached to, so a + * removed-and-re-added target reusing the id can't silently adopt it. Absent on + * local records, on legacy records, and on orphans whose target is gone. */ + executionTargetGeneration?: number schedulerOwner: AutomationSchedulerOwner workspaceMode: AutomationWorkspaceMode workspaceId: string | null @@ -118,6 +123,10 @@ export type Automation = { rrule: string dtstart: number enabled: boolean + /** Why: who last decided `enabled`. The load migration disables an orphan once and + * stamps itself; `user` is the user overruling that, which no later load may undo. + * Absent on records the migration has never acted on. */ + enabledDecidedBy?: AutomationEnablementDecider nextRunAt: number lastRunAt?: number missedRunPolicy: AutomationMissedRunPolicy @@ -156,6 +165,12 @@ export type AutomationRun = { /** Why: run titles must stay unique once retention prunes old runs, so the * number can no longer be derived from how many runs are currently kept. */ runNumber?: number + /** Why: a target that cannot resolve refuses every occurrence, so consecutive + * identical refusals fold into this record instead of one row each. Counts the + * occurrences the record stands for; absent means one. */ + occurrenceCount?: number + /** `scheduledFor` of the most recently folded occurrence; absent until one folds. */ + lastOccurrenceAt?: number } export type AutomationCreateInput = { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 811aada6144..cddcf97b5e3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -442,6 +442,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { workspaceSession: getDefaultWorkspaceSession(), workspaceSessionsByHostId: {}, sshTargets: [], + sshTargetGenerationCounter: 0, deletedSshConfigAliases: [], sshRemotePtyLeases: [], sshPtyConsumerRecoveries: [], @@ -476,6 +477,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceHostScope: 'all', visibleWorkspaceHostIds: null, workspaceHostOrder: [], + automationHostFilter: { kind: 'all' }, manualRepoOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, diff --git a/src/shared/external-automation-scope.ts b/src/shared/external-automation-scope.ts new file mode 100644 index 00000000000..0e9fe753a5c --- /dev/null +++ b/src/shared/external-automation-scope.ts @@ -0,0 +1,155 @@ +/** + * Scoped external-automation requests. + * + * External managers are a desktop-only surface, so every scoped call carries the + * captured desktop `AutomationOwnerRef` it was built from. The provider target is + * *derived* from that owner and never accepted alongside it, so a caller cannot + * name one host's owner while asking for another host's manager. + * + * The host-changed failure reuses `AutomationOwnerConflictError` rather than + * introducing a second conflict vocabulary; the codes below cover only the + * failures that are specific to this surface. + */ + +import { + AUTOMATION_OWNER_CONFLICT_CODES, + AutomationOwnerConflictError +} from './automation-owner-conflict' +import type { AutomationOwnerRef } from './automation-owner-ref' +import type { + ExternalAutomationAction, + ExternalAutomationProvider, + ExternalAutomationTarget +} from './automations-types' + +export const EXTERNAL_AUTOMATION_PROVIDERS = [ + 'hermes', + 'openclaw' +] as const satisfies readonly ExternalAutomationProvider[] + +export function isExternalAutomationProvider(value: unknown): value is ExternalAutomationProvider { + return ( + typeof value === 'string' && + (EXTERNAL_AUTOMATION_PROVIDERS as readonly string[]).includes(value) + ) +} + +export const EXTERNAL_AUTOMATION_SCOPE_CODES = { + /** The provider is not on the allowlist, so no command or relay call may be attempted. */ + providerNotAllowed: 'external_automation_provider_not_allowed', + /** Only the desktop authority owns external managers; runtime authorities must not be tunnelled. */ + authorityNotSupported: 'external_automation_authority_not_supported', + /** The SSH target is an Orca implementation detail and stays out of user-facing surfaces. */ + targetHidden: 'external_automation_target_hidden', + /** The relay answered `-32601`: it is healthy but has no external-runs method. */ + runsUnsupported: 'external_automation_runs_unsupported' +} as const + +export type ExternalAutomationScopeCode = + (typeof EXTERNAL_AUTOMATION_SCOPE_CODES)[keyof typeof EXTERNAL_AUTOMATION_SCOPE_CODES] + +const SCOPE_MESSAGES: Record = { + [EXTERNAL_AUTOMATION_SCOPE_CODES.providerNotAllowed]: + 'That external automation provider is not supported.', + [EXTERNAL_AUTOMATION_SCOPE_CODES.authorityNotSupported]: + 'External automation managers are available only on this computer and its SSH hosts.', + [EXTERNAL_AUTOMATION_SCOPE_CODES.targetHidden]: + 'That host is managed by Orca and does not expose external automations.', + [EXTERNAL_AUTOMATION_SCOPE_CODES.runsUnsupported]: + 'This host does not report external automation run history.' +} + +/** + * Carries its code as a trailing `: ` token, matching the automation owner + * conflicts, because Electron IPC and older runtimes preserve nothing but the message. + */ +export class ExternalAutomationScopeError extends Error { + readonly code: ExternalAutomationScopeCode + + constructor(code: ExternalAutomationScopeCode) { + super(`${SCOPE_MESSAGES[code]}: ${code}`) + this.name = 'ExternalAutomationScopeError' + this.code = code + } +} + +const SCOPE_CODE_VALUES: readonly ExternalAutomationScopeCode[] = Object.values( + EXTERNAL_AUTOMATION_SCOPE_CODES +) + +export function isExternalAutomationScopeCode( + value: unknown +): value is ExternalAutomationScopeCode { + return ( + typeof value === 'string' && SCOPE_CODE_VALUES.includes(value as ExternalAutomationScopeCode) + ) +} + +/** The captured incarnation no longer matches the current SSH registration. */ +export function externalAutomationHostChangedError(): AutomationOwnerConflictError { + return new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) +} + +/** The captured SSH host is gone, so nothing may be probed or launched on it. */ +export function externalAutomationTargetRemovedError(): AutomationOwnerConflictError { + return new AutomationOwnerConflictError(AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved) +} + +/** Every scoped request; the target is derived from `owner`, never sent beside it. */ +export type ScopedExternalAutomationRequest = { + owner: AutomationOwnerRef + provider: ExternalAutomationProvider +} + +export type ScopedExternalManagerListRequest = ScopedExternalAutomationRequest & { + /** Skips a cached entry that is still fresh; a probe is scheduled either way when absent. */ + refresh?: boolean +} + +export type ScopedExternalManagerRunsRequest = ScopedExternalAutomationRequest & { + jobId: string + page: number + pageSize: number +} + +export type ScopedExternalManagerMutationFields = { + name: string + prompt: string + schedule: string + workdir: string | null +} + +export type ScopedExternalManagerCreateRequest = ScopedExternalAutomationRequest & + ScopedExternalManagerMutationFields + +export type ScopedExternalManagerUpdateRequest = ScopedExternalManagerCreateRequest & { + jobId: string +} + +export type ScopedExternalManagerActionRequest = ScopedExternalAutomationRequest & { + jobId: string + action: ExternalAutomationAction +} + +/** Target IDs stay identifiers: this is the only place a selector becomes a target. */ +export function externalAutomationTargetForOwner( + owner: AutomationOwnerRef +): ExternalAutomationTarget { + return owner.selector.kind === 'ssh' + ? { type: 'ssh', connectionId: owner.selector.targetId } + : { type: 'local' } +} + +/** + * Manager identity for a scope. The authority is implicit rather than encoded: + * this surface is desktop-only, and the existing IDs are already persisted in + * job, run, and dialog keys that older renderers still parse. + */ +export function externalAutomationManagerId( + owner: AutomationOwnerRef, + provider: ExternalAutomationProvider +): string { + return owner.selector.kind === 'ssh' + ? `${provider}:ssh:${owner.selector.targetId}` + : `${provider}:local` +} diff --git a/src/shared/folder-workspace-execution-host.test.ts b/src/shared/folder-workspace-execution-host.test.ts new file mode 100644 index 00000000000..d2f84fc7a45 --- /dev/null +++ b/src/shared/folder-workspace-execution-host.test.ts @@ -0,0 +1,175 @@ +/** + * The pin cases are the ones that used to diverge: main resolved the repo and + * said local while the renderer resolved the workspace and said SSH. + */ + +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from './folder-workspace-types' +import type { ProjectGroup } from './project-group-types' +import type { Repo } from './repo-types' +import { + resolveFolderWorkspaceHost, + type FolderWorkspaceHostState +} from './folder-workspace-execution-host' + +function workspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'group-1', + name: 'App', + folderPath: '/work/app', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function repo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: '#000', + addedAt: 0, + ...overrides + } as Repo +} + +function state(overrides: Partial = {}): FolderWorkspaceHostState { + return { + folderWorkspaces: [workspace()], + projectGroups: [ + { + id: 'group-1', + name: 'Work', + parentPath: '/work', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + } satisfies ProjectGroup + ], + repos: [repo({ id: 'repo-1', path: '/work/app/repo', projectGroupId: 'group-1' })], + ...overrides + } +} + +describe('folder workspace execution host', () => { + it('answers with the workspace pin even when every repo under it is local', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + folderWorkspaces: [workspace({ executionHostId: 'ssh:box' })] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + it('honours a local pin over an SSH repo', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + folderWorkspaces: [workspace({ executionHostId: 'local' })], + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/repo', + projectGroupId: 'group-1', + connectionId: 'box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'local' }) + }) + + it('reports a scope that mixes local and SSH repos as ambiguous', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ id: 'repo-1', path: '/work/app/a', projectGroupId: 'group-1' }), + repo({ + id: 'repo-2', + path: '/work/app/b', + projectGroupId: 'group-1', + connectionId: 'box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ambiguous' }) + }) + + it('reports two SSH connections as ambiguous', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + connectionId: 'box' + }), + repo({ + id: 'repo-2', + path: '/work/app/b', + projectGroupId: 'group-1', + connectionId: 'other' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ambiguous' }) + }) + + it('resolves a single SSH connection', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ + id: 'repo-1', + path: '/work/app/a', + projectGroupId: 'group-1', + connectionId: 'box' + }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' }) + }) + + // Nothing normalizes connection ids on write, so a blank one is local on both sides. + it('treats a blank connection id as local rather than as a host named " "', () => { + const resolved = resolveFolderWorkspaceHost( + state({ + repos: [ + repo({ id: 'repo-1', path: '/work/app/a', projectGroupId: 'group-1', connectionId: ' ' }) + ] + }), + 'fw-1' + ) + + expect(resolved).toEqual({ kind: 'local' }) + }) + + it('separates a workspace that is gone from one that resolves to local', () => { + expect(resolveFolderWorkspaceHost(state(), 'fw-missing')).toEqual({ kind: 'missing' }) + expect(resolveFolderWorkspaceHost(state({ repos: [] }), 'fw-1')).toEqual({ kind: 'local' }) + }) +}) diff --git a/src/shared/folder-workspace-execution-host.ts b/src/shared/folder-workspace-execution-host.ts new file mode 100644 index 00000000000..0e83a20469f --- /dev/null +++ b/src/shared/folder-workspace-execution-host.ts @@ -0,0 +1,135 @@ +/** + * Resolves which host a folder workspace executes on. + * + * Main and the renderer both have to answer this — the list projects a record's + * host from it, dispatch targets one from it — so the answer lives here rather + * than being re-derived per side and drifting. The workspace's own + * `executionHostId` pin wins: a pinned workspace runs there whatever its repos + * say, which is exactly the case that used to read as local. + * + * `ambiguous` is a distinct answer, not a missing one: a scope that spans a + * local repo and an SSH one has no single host, and callers must fail closed + * with something visible rather than defaulting to local. + */ + +import type { FolderWorkspace } from './folder-workspace-types' +import type { ProjectGroup } from './project-group-types' +import type { Repo } from './repo-types' +import { isPathInsideOrEqual } from './cross-platform-path' +import { getProjectGroupSubtreeIds } from './project-groups' +import { parseExecutionHostId } from './execution-host' + +export type FolderWorkspaceHostState = { + folderWorkspaces: readonly FolderWorkspace[] + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +} + +export type FolderWorkspaceHost = + | { kind: 'missing' } + | { kind: 'local' } + | { kind: 'ssh'; targetId: string } + | { kind: 'ambiguous' } + +/** Reads a stored connection id: blank is local, since no write path normalizes it. */ +export function normalizeConnectionId(value: string | null | undefined): string | null { + return value?.trim() || null +} + +function getFolderScopeCandidateRepos(args: { + folderPath: string + projectGroupId: string + connectionId: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): Repo[] { + const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(args.folderPath, repo.path) + ) + if (args.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => normalizeConnectionId(repo.connectionId) === args.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set( + groupRepos.map((repo) => normalizeConnectionId(repo.connectionId)) + ) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(normalizeConnectionId(repo.connectionId))) + ] +} + +export function findFolderWorkspaceCandidateRepos( + state: FolderWorkspaceHostState, + folderWorkspaceId: string +): Repo[] { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return [] + } + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + return getFolderScopeCandidateRepos({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: normalizeConnectionId(workspace.connectionId ?? group?.connectionId), + projectGroups: state.projectGroups, + repos: state.repos + }) +} + +export function resolveFolderWorkspaceHost( + state: FolderWorkspaceHostState, + folderWorkspaceId: string +): FolderWorkspaceHost { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return { kind: 'missing' } + } + const explicitHost = parseExecutionHostId(workspace.executionHostId) + if (explicitHost) { + return explicitHost.kind === 'ssh' + ? { kind: 'ssh', targetId: explicitHost.targetId } + : { kind: 'local' } + } + const scopeConnectionId = normalizeConnectionId( + workspace.connectionId ?? + state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId + ) + const candidateRepos = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId) + let hasLocalRepo = false + const connectionIds = new Set() + for (const repo of candidateRepos) { + const connectionId = normalizeConnectionId(repo.connectionId) + if (connectionId) { + connectionIds.add(connectionId) + } else { + hasLocalRepo = true + } + } + if (scopeConnectionId) { + const hasDifferentSshConnection = [...connectionIds].some( + (connectionId) => connectionId !== scopeConnectionId + ) + return hasLocalRepo || hasDifferentSshConnection + ? { kind: 'ambiguous' } + : { kind: 'ssh', targetId: scopeConnectionId } + } + if (candidateRepos.length === 0 || connectionIds.size === 0) { + return { kind: 'local' } + } + if (hasLocalRepo || connectionIds.size > 1) { + return { kind: 'ambiguous' } + } + return { kind: 'ssh', targetId: [...connectionIds][0] } +} diff --git a/src/shared/folder-workspaces.test.ts b/src/shared/folder-workspaces.test.ts new file mode 100644 index 00000000000..c0b023c6b77 --- /dev/null +++ b/src/shared/folder-workspaces.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { normalizeFolderWorkspaces } from './folder-workspaces' +import type { ProjectGroup } from './project-group-types' + +const folderGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/tmp/projects', + connectionId: null +} as unknown as ProjectGroup + +describe('normalizeFolderWorkspaces host attribution', () => { + it('drops a stored executionHostId instead of round-tripping it', () => { + const [workspace] = normalizeFolderWorkspaces( + [ + { + id: 'ws-1', + projectGroupId: 'group-1', + name: 'Nightly', + folderPath: '/tmp/projects/nightly', + connectionId: null, + executionHostId: 'runtime:env-7' + } + ], + [folderGroup] + ) + + // A runtime-scoped stamp names an authority the desktop store does not own, and it + // carries no generation to fence on — persisting it would recreate the divergence #12 fixed. + expect(workspace).toBeDefined() + expect(workspace.executionHostId).toBeUndefined() + expect(Object.keys(workspace)).not.toContain('executionHostId') + }) + + it('keeps connectionId as the durable host pin', () => { + const [pinned] = normalizeFolderWorkspaces( + [ + { + id: 'ws-2', + projectGroupId: 'group-1', + name: 'Pinned', + folderPath: '/tmp/projects/pinned', + connectionId: 'ssh-box', + executionHostId: 'local' + } + ], + [folderGroup] + ) + + expect(pinned.connectionId).toBe('ssh-box') + expect(pinned.executionHostId).toBeUndefined() + }) + + it('inherits the group connection when the workspace omits one', () => { + const [inherited] = normalizeFolderWorkspaces( + [{ id: 'ws-3', projectGroupId: 'group-1', name: 'Inherited' }], + [{ ...folderGroup, connectionId: 'ssh-group' } as ProjectGroup] + ) + + expect(inherited.connectionId).toBe('ssh-group') + }) +}) diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 97409bd271d..32617981912 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -57,6 +57,9 @@ export function normalizeFolderWorkspaces( const linkedTaskSourceContext = normalizeStoredTaskSourceContext(raw.linkedTaskSourceContext) const creatorProvenance = normalizeWorkspaceCreatorProvenance(raw.creatorProvenance) seen.add(raw.id) + // Why: `executionHostId` is deliberately NOT persisted. It is a fetch-time renderer + // stamp that can name a `runtime:*` authority the desktop store does not own, and it + // carries no generation to fence on. `connectionId` below is the durable pin main projects from. workspaces.push({ id: raw.id, projectGroupId: raw.projectGroupId, diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index ee6d1f97fa7..7eaf0e46de5 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -90,6 +90,8 @@ export type PersistedState = { /** Per-execution-host session partitions for non-'local' hosts (ssh:/runtime:); 'local' stays in workspaceSession so pre-partition builds keep working. */ workspaceSessionsByHostId?: Partial> sshTargets: SshTarget[] + /** Highest SSH target registration generation ever issued. Reloaded as a high-water mark over stored targets and automations, so a rollback can't reissue a captured generation. */ + sshTargetGenerationCounter?: number /** SSH config aliases the user deleted; suppresses re-import from ~/.ssh/config so a deleted host doesn't reappear. */ deletedSshConfigAliases: string[] /** Identity records for removed SSH targets so a re-added host can re-adopt workspaces orphaned on the old target id. */ diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 1db7f8e5dfc..00d7adf9edd 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -21,6 +21,7 @@ import type { WorkspaceHostScope, WorktreeCardProperty } from './ui-chrome-types' +import type { PersistedAutomationHostFilter } from './automation-host-filter' import type { WorkspaceStatusDefinition } from './worktree/types' export type PersistedUIState = { @@ -49,6 +50,8 @@ export type PersistedUIState = { visibleWorkspaceHostIds?: VisibleWorkspaceHostIds /** User-defined sidebar order for host sections; missing/new hosts append in discovered order. */ workspaceHostOrder?: WorkspaceHostOrder + /** Automations page host filter. Stores only the stable form (a canonical `hostStableKey`); an unparseable value degrades to All hosts. */ + automationHostFilter?: PersistedAutomationHostFilter /** Desktop-owned all-host repo order; host-qualified identities keep a manual cross-host interleaving while each host owns its local permutation. */ manualRepoOrder?: ManualRepoOrderEntry[] /** Deprecated legacy positive-form setting. Ignored on hydration. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d9d356c676a..13391a1a13c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -113,6 +113,15 @@ export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-defaults.v1' as const export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-source-defaults.v1' as const +// Why: older hosts drop automation.list's selector and answer with the whole authority, so a scoped client must not read that as one host's rows. +export const AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY = + 'automation.list-host-scope.v1' as const +export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE = + 'Filtering automations by host requires a newer Orca server. Update the HUB and try again.' +// Why: without server-side owner preconditions a mutation could run against a host the user never saw, so unfenced rows stay view-only. +export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const +export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE = + 'Editing automations on this host requires a newer Orca server. Update the HUB and try again.' export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -161,7 +170,9 @@ export const RUNTIME_CAPABILITIES = [ SKILL_INSTALL_RESULT_V2_CAPABILITY, SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, - SKILL_INSTALL_PROVIDERS_CAPABILITY + SKILL_INSTALL_PROVIDERS_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index c849f2f6630..a9d92b5a57d 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -25,6 +25,13 @@ export type RuntimeClientEvent = ptyIds: string[] terminalHandles: string[] } + // Why: automation stores are authority-owned, so clients cannot learn about a + // run/usage/definition write without the owning authority announcing it. + | { + type: 'automationsChanged' + selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' } + reason?: 'definition' | 'run' | 'usage' + } | { type: 'linearLinkedIssueUpdated' worktreeId: string @@ -52,6 +59,13 @@ export type RuntimeClientEventStreamMessage = export type RuntimeActivateWorktreeEvent = Extract +export type AutomationsChangedEvent = Extract + +/** Publisher payload; `selector` scoping is added by a later host-scope step. */ +export type AutomationsChangedPayload = Omit + +export type PublishAutomationsChanged = (payload: AutomationsChangedPayload) => void + export function toRuntimeActivateWorktreeEvent( repoId: string, worktreeId: string, diff --git a/src/shared/runtime-rpc-error-code.ts b/src/shared/runtime-rpc-error-code.ts new file mode 100644 index 00000000000..328df8402ab --- /dev/null +++ b/src/shared/runtime-rpc-error-code.ts @@ -0,0 +1,66 @@ +/** + * Classifies a runtime RPC failure by its machine token. + * + * Shared rather than renderer-owned because every client reads the same + * flattened shapes: Electron IPC rewraps the message ("Error invoking remote + * method 'x': Error: …") and a host that does not know an error class maps it + * to `runtime_error` with the token left on the message. A client that matches + * only `.code` therefore classifies a subset of what it receives. + */ + +// Why: transports re-wrap the token into a longer message and drop the cause, so the +// token classifies only as the whole message or after a real message boundary (": " or a +// newline) — prose that merely trails off in the token must not classify. +const CODE_TOKEN_BOUNDARY = /(?:: |\n)[ \t]*$/ + +function endsWithCodeToken(text: string, expectedCode: string): boolean { + const trimmed = text.trimEnd() + if (!trimmed.endsWith(expectedCode)) { + return false + } + const prefix = trimmed.slice(0, -expectedCode.length) + return prefix.trim() === '' || CODE_TOKEN_BOUNDARY.test(prefix) +} + +export function hasRuntimeRpcErrorCode(error: unknown, expectedCode: string): boolean { + const seen = new Set() + let current = error + while (!seen.has(current)) { + if (typeof current === 'string') { + return endsWithCodeToken(current, expectedCode) + } + if (!current || typeof current !== 'object') { + return false + } + seen.add(current) + const candidate = current as { + cause?: unknown + code?: unknown + message?: unknown + error?: { code?: unknown; message?: unknown } + response?: { error?: { code?: unknown; message?: unknown } } + } + // Machine tokens are checked before messages so a subclass with a human-readable message still classifies by code. + if ( + candidate.code === expectedCode || + candidate.error?.code === expectedCode || + candidate.response?.error?.code === expectedCode + ) { + return true + } + const messages = [ + candidate.message, + candidate.error?.message, + candidate.response?.error?.message + ] + if ( + messages.some( + (message) => typeof message === 'string' && endsWithCodeToken(message, expectedCode) + ) + ) { + return true + } + current = candidate.cause + } + return false +} diff --git a/src/shared/ssh-target-generation.test.ts b/src/shared/ssh-target-generation.test.ts new file mode 100644 index 00000000000..c65aa56f549 --- /dev/null +++ b/src/shared/ssh-target-generation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + nextSshTargetGeneration, + resolveSshTargetGenerationHighWaterMark, + sanitizeSshTargetGeneration +} from './ssh-target-generation' + +describe('sanitizeSshTargetGeneration', () => { + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '3', null, undefined, {}])( + 'rejects %p', + (value) => { + expect(sanitizeSshTargetGeneration(value)).toBeUndefined() + } + ) + + it('accepts issued generations', () => { + expect(sanitizeSshTargetGeneration(1)).toBe(1) + expect(sanitizeSshTargetGeneration(42)).toBe(42) + }) +}) + +describe('resolveSshTargetGenerationHighWaterMark', () => { + it('never reissues a generation an automation already captured', () => { + // The counter rolled back to 2, but an automation is fenced on 9. + const highWaterMark = resolveSshTargetGenerationHighWaterMark({ + persistedCounter: 2, + targetGenerations: [1, 2], + capturedGenerations: [9] + }) + expect(highWaterMark).toBe(9) + expect(nextSshTargetGeneration(highWaterMark)).toBe(10) + }) + + it('never reissues a generation a stored target already carries', () => { + expect( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: 0, + targetGenerations: [7], + capturedGenerations: [] + }) + ).toBe(7) + }) + + it('keeps the persisted counter when it leads', () => { + expect( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: 12, + targetGenerations: [3], + capturedGenerations: [5] + }) + ).toBe(12) + }) + + it('ignores corrupt values on every input', () => { + expect( + resolveSshTargetGenerationHighWaterMark({ + persistedCounter: Number.NaN, + targetGenerations: [undefined, -4, 2.5], + capturedGenerations: [null] + }) + ).toBe(0) + expect(nextSshTargetGeneration(0)).toBe(1) + }) +}) diff --git a/src/shared/ssh-target-generation.ts b/src/shared/ssh-target-generation.ts new file mode 100644 index 00000000000..1696789b1d0 --- /dev/null +++ b/src/shared/ssh-target-generation.ts @@ -0,0 +1,57 @@ +/** + * Monotonic allocation of SSH target *registration* generations. + * + * A generation identifies one registration incarnation of a target ID: it + * advances when a target is created, deleted and re-created, or explicitly + * re-adopted. Connect, reconnect, and status transitions never advance it — + * that is `SshConnectionState.connectionGeneration`, a different counter. + * + * The persisted counter is the *highest generation ever issued*. On load it is + * reloaded as a high-water mark over the counter, every stored target, and + * every generation an automation already captured, so a counter lost to a + * rollback can never reissue a generation an automation is still fenced on. + */ + +export const FIRST_SSH_TARGET_GENERATION = 1 + +/** Accept only values that could have been issued; anything else is treated as absent. */ +export function sanitizeSshTargetGeneration(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isSafeInteger(value) && + value >= FIRST_SSH_TARGET_GENERATION + ? value + : undefined +} + +export type SshTargetGenerationHighWaterInput = { + /** Highest generation the persisted counter claims to have issued. */ + persistedCounter?: number | null + /** Generations stamped on stored SSH targets. */ + targetGenerations: Iterable + /** Generations captured by stored automations — the reason a rollback cannot be trusted. */ + capturedGenerations: Iterable +} + +function highest(values: Iterable, seed: number): number { + let result = seed + for (const value of values) { + const sanitized = sanitizeSshTargetGeneration(value) + if (sanitized !== undefined && sanitized > result) { + result = sanitized + } + } + return result +} + +/** Highest generation considered already issued; the next allocation is this + 1. */ +export function resolveSshTargetGenerationHighWaterMark( + input: SshTargetGenerationHighWaterInput +): number { + const seed = sanitizeSshTargetGeneration(input.persistedCounter) ?? 0 + return highest(input.capturedGenerations, highest(input.targetGenerations, seed)) +} + +export function nextSshTargetGeneration(highWaterMark: number): number { + const sanitized = sanitizeSshTargetGeneration(highWaterMark) ?? 0 + return sanitized + 1 +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index fd6a9a3bcb0..dd242bec28a 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -53,10 +53,14 @@ export type SshTarget = { /** Reuse a system OpenSSH connection across setup commands. Undefined means * enabled; false is an explicit per-target compatibility opt-out. */ systemSshConnectionReuse?: boolean + /** Durable registration incarnation. Advances on create / re-create / explicit + * re-adopt only, so automations fenced on an old registration cannot run on a + * later target that happens to reuse the id. Never advanced by connect state. */ + generation?: number } /** Public target identity safe to mirror to a paired client. */ -export type SshTargetSummary = Pick +export type SshTargetSummary = Pick /** Identity of a removed SSH target, recorded so that re-adding the same host * can re-point orphaned repos/worktrees from the old (deleted) target id to @@ -73,6 +77,11 @@ export type RemovedSshTargetTombstone = { label: string /** ms epoch when the target was removed, for pruning old tombstones. */ removedAt: number + /** `automation-scan` tombstones are synthesized from a stored automation's dead + * target reference, so they carry a label but no real host identity. They must + * never match a re-added host on the host/port/username tuple. Undefined means + * `removed` (a real removal that recorded the target's identity). */ + origin?: 'removed' | 'automation-scan' } /** Exact repo ownership changes made while re-adopting a removed SSH host. */