From 3fbed6612eb31345024d5d72ecdb2fa9c4f282d3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:31:56 -0700 Subject: [PATCH] docs: remove tracked design plans (#16663) --- .gitignore | 3 - ...-skill-sharing-implementation-checklist.md | 2 - docs/agent-skill-sharing-installation-plan.md | 1601 ----------------- docs/ai-vault-process-isolation-plan.md | 596 ------ docs/automations-all-hosts-design.md | 530 ------ docs/mobile-relay-ux-findings.md | 442 ----- 6 files changed, 3174 deletions(-) delete mode 100644 docs/agent-skill-sharing-installation-plan.md delete mode 100644 docs/ai-vault-process-isolation-plan.md delete mode 100644 docs/automations-all-hosts-design.md delete mode 100644 docs/mobile-relay-ux-findings.md diff --git a/.gitignore b/.gitignore index a46e4f060f8..efaee0a2e38 100644 --- a/.gitignore +++ b/.gitignore @@ -98,9 +98,6 @@ docs/** !docs/readme/** !docs/STYLEGUIDE.md !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/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index 2ff54d16a7e..91eecf55990 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -314,8 +314,6 @@ Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL se identical to macOS. The user-driven signed-in desktop and real-host production journey, supported Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep. -Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md). - This checklist turns the architecture plan into ordered implementation and release work. A checked item means evidence exists in code, tests, reviewed infrastructure, or release documentation; it does not mean the surrounding phase is complete. diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md deleted file mode 100644 index bfda3cab054..00000000000 --- a/docs/agent-skill-sharing-installation-plan.md +++ /dev/null @@ -1,1601 +0,0 @@ -# Agent skill sharing and installation plan - -Status: implementation and validation in progress. - -Last updated: 2026-08-12. - -Implementation checklist: -[Agent skill sharing implementation checklist](./agent-skill-sharing-implementation-checklist.md). - -Architecture decision: -[Agent skill sharing upstream boundary](./reference/agent-skill-sharing-upstream-boundary.md). - -Provider registry: -[Agent skill provider paths](./reference/agent-skill-provider-paths.md). - -## Decision summary - -Orca will own a focused Skill Bundle packaging and installation pipeline for private sharing. A -bundle contains one or many skills; sharing one skill is the same flow with one selected item. - -The community [`vercel-labs/skills`](https://github.com/vercel-labs/skills) project will -remain a behavioral reference, not a runtime dependency or source donor. Its npm package -exposes a CLI rather than a supported programmatic API, and its install/update behavior does -not provide the transaction, provenance, or private-package semantics Orca needs. - -Orca will: - -1. Package one or many local skills into one immutable, content-addressed artifact and durable - link. -2. Store private artifacts behind unlisted, revocable Orca Cloud share records. -3. Treat possession of an active share link as recipient authorization, then resolve it into a - short-lived download grant. -4. Execute installation on the machine that will use the skill. -5. Validate, stage, commit, and verify the package without silently overwriting local work. -6. Let the recipient install all or a selected subset, with conflicts and results reported per - skill. -7. Install each selected skill once into `.agents/skills` and add aliases only for detected agents - that need provider-specific paths. -8. Record bundle/version provenance independently from the community CLI's lockfiles. -9. Keep every share unlisted: bundles are reachable only through their durable link and never - appear in search, browse, organization-library, or marketplace surfaces. -10. Give authenticated owners a private active-link inventory for copy and revocation; this is a - management surface, not recipient discovery. - -The portable archive root follows Agent Plugins 1.0.0 for a skills-only package. Orca does not -present Skill Bundles as a new executable plugin system: MCP servers, hooks, processes, connectors, -and permissions are out of scope. Loose-skill installation remains the universal/default delivery -path, with provider-native plugin adapters generated only where they improve compatibility. - -The first release will support Orca package sources only. Arbitrary Git, npm, and community -registry sources remain delegated to existing tools until there is a product need to own -that larger package-manager surface. - -## Current execution status - -The Orca implementation is on `skills-share` through `2a0b7b2acf`; no Orca pull request exists. -The local working tree also contains the install-IPC compatibility fix and the in-progress Skills -UI overhaul. Cloud bundle ingestion, bearer links, recovery, load testing, production bootstrap, -OIDC smoke, and monitor ownership merged through `stablyai/orca-cloud#361`. Final infrastructure -hardening merged in `#364` as `57c4958979fc32ed4753959cea45edd3a7c8775c`. - -Production is live. Two reviewed targeted Terraform phases added the dedicated app deploy identity, -restricted both GitHub WIF providers to exact workflows, added resource-scoped API/Auth/monitor and -Relay bindings, then removed five broad legacy bindings. A separate exact plan updated only the -bearer request-log exclusion and all eight skill alert policies; the alerts use the dedicated -`Orca skill sharing alerts` email channel. No full production plan was applied because it contains -unrelated Relay drift. - -Production deploy run `31661421728` promoted `orca-cloud-api-00025-qup` at 100% traffic from merged -`main`, using immutable digest -`sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`. Candidate and canonical -authenticated artifact and skill smoke passed, `/health` and `/ready` are green, and the direct -unlisted landing route returns only generic install copy plus the Orca deep link with `no-store`, -`no-referrer`, and restrictive CSP headers. Monitor execution -`orca-cloud-skill-storage-monitor-lfd26` passed on the same digest and emitted only aggregate -inventory. The implementation checklist records the remaining desktop and physical-host gates. - -The final reliability hardening gives every skill Cloud request a default deadline, makes bundle -extraction cancellable and recoverable through the durable extraction journal, bounds package and -preview fanout, atomically caps concurrent share preparation, removes abandoned private staging, -and waits for SSH transaction recovery before install management. Paired clients advertise the -additive install-result capability so cancellation remains cancellation across mixed versions. -Regression coverage includes a real killed child process with partially extracted private bytes -and bounded 17-skill remote preview fanout. Full Node, CLI, and web typechecks plus repository lint -and reliability gates pass at that baseline. - -The final combined physical `windows 2` run at `44d1266641` passed 449 tests across 67 files with -17 intentional platform skips while native-Windows, real-process, and Ubuntu 24.04 WSL coverage -were enabled together. Windows harness fixes at `97b831dd17` made recovery evidence path-semantic -and time-deterministic. The run exposed and fixed a WSL alias edge at `44d1266641`: when native UNC -`lstat` misses an existing distro symlink, Orca now verifies the alias through WSL and uses -`ln -sT` for creation. The Windows checkout was clean and WSL `/tmp` had no test debris. Only -Ubuntu 24.04 is installed; second-distro permutations are post-launch rather than a first-release -gate. - -The release workflow now runs the exact unpacked Linux package under Electron-as-Node in an -Ubuntu 20.04 container and requires packaged `node-pty` to spawn `/bin/sh`. A physical x64 build at -`f1dccb4f42` passed the existing 18-binary ABI scan and this load/spawn smoke. A native ARM64 build -at `abe92d565b` then passed the same scan for all 18 bundled binaries and emitted -`orca-node-pty-floor-ok` from an ARM64 Ubuntu 20.04 container on native `aarch64` hardware. The -isolated ARM64 GCE builder had no service account or scopes, remained clean at the exact source -commit, and was deleted with its auto-delete boot disk after verification. The physical runs also -caught and corrected assumptions about the Linux executable name and packaged runtime-module -location before the gate protects a real release. - -Cloud verification now requires a disposable PostgreSQL 16/17 migration-and-restore drill. An old -migration process remains connected while a future-additive process advances the schema; both -write successfully, the database is dumped and restored, and both write again against the restored -copy. PR run `31624484826` and merged-main run `31624717796` passed all code, Terraform, and -database jobs. The drill is localhost-only, cleans both schemas and its backup, and does not touch -staging or production services. - -The local redesign now has an independently implemented Agent Plugins 1.0.0 skills-only bundle -manifest, deterministic one-or-many archive creation, bounded bundle extraction, additive bundle -install contracts, per-skill conflict/results orchestration, and bundle provenance on installed -skill receipts. The recipient dialog now recognizes bundle versions, defaults to all skills, -supports subset selection, previews aggregate destination state, keeps conflicts local by default, -and groups per-skill outcomes with failed-only retry. Local and paired-runtime bundle grants use the -additive bundle RPC with staged-upload fallback. SSH now uses a separately advertised additive -bundle relay method with the same direct-download and staged-upload fallback; older hosts are -rejected before transfer. The original single-skill path remains active for legacy shares while -bundle management migrates. - -Cloud bundle ingestion accepts both envelopes during migration and stores the detailed bundle -manifest with the immutable version. Active share IDs are the recipient credential, owner -operations remain authenticated, and legacy ACL rows remain only as migration storage. A -Terraform-managed Cloud Logging exclusion prevents per-link Cloud Run request URLs from being -stored while privacy-safe route-template telemetry remains available. - -The latest resilience pass adds bounded convergence after a lost final install response for both -paired runtimes and SSH. A staged retry creates a new upload instead of reusing a consumed upload. -SSH now carries only schema-validated structured skill failures through optional JSON-RPC -`error.data`, while older peers continue to ignore the field and newer clients fall back safely -when it is absent. Invalid gzip input has a stable archive category, and deterministic `EACCES` -and `ENOSPC` injection proves failed updates preserve the previous installed version. - -Desktop skill operations now have privacy-bounded local diagnostic spans for package creation, -Cloud upload and finalization, grant download, client-mediated runtime and SSH transfer, single -and bundle installation, provider placement, transaction rollback settlement, and startup -recovery. They record aggregate bytes/files, phase duration, OS, destination and transport, -conflict/topology/mechanism outcomes, copy fallback, capability absence, recovery/orphan counts, -and bounded error categories without paths, skill names, contents, share URLs, grants, host IDs, -or credentials. Bundle summaries are aggregate and cap error-category cardinality at 32. This is -not product telemetry and changes no RPC, persisted state, or remote opcode, so old clients and -hosts remain compatible. The release suite passes 427 tests, full typecheck/lint pass, and the -real-process macOS crash-recovery matrix passes all 17 boundaries; physical platform reruns remain -tracked in the checklist. - -The dedicated staging bucket, IAM, database, principal, enabled secret version, metrics, dashboard, -and alerts exist in `onorca-cloud-staging`. The complete skill-infrastructure target is zero-diff. -After `#314`, the targeted Auth/API plan also reports zero intended changes. Known shared Cloud SQL -and artifact-bucket drift was excluded and never applied. Staging was awakened only through guarded -run `31534564230`. Sleep run `31536547916` returned all three cells to zero; read-only status run -`31537525219` independently confirmed SQL policy `NEVER`, zero MIG targets, and zero managed Cloud -Run active-revision minimums. - -The API release workflow now deploys an immutable no-traffic candidate with the complete artifact -and skill environment plus exactly one approved skill-database secret. It verifies the inherited -Terraform runtime identity, Cloud SQL attachment, scaling, resources, volumes, mounts, ports, and -probes before promotion, and rolls back to a re-smoked prior revision on failure. Staging now has -all four controls enabled; production keeps all four disabled. Database migration startup uses a -transaction-scoped advisory lock; transactional rollback and eight concurrent callers pass against -PostgreSQL 16 and 17. - -Cloud PR `#344` passed both required checks and merged as `fcf8655a`. It adds privacy-bounded -migration-ready and migration-failure metrics, a migration lifecycle dashboard panel, and a -zero-tolerance migration-failure alert. A read-only targeted staging plan creates only those -metrics and alert, updates the existing skill dashboard, reruns descriptor propagation, and -destroys no infrastructure; staging remained asleep and nothing was applied. - -Cloud PR `#345` passed both required checks and merged as `06a5c729`. It completes the initial API -dashboard coverage with explicit finalization-saturation and archive-rejection series and displays -successful and failed operations as rates. Malformed compression errors now become the bounded -`skill_package_archive_invalid` category before they reach persistence, responses, or telemetry. - -Cloud PR `#346` passed both required checks and merged as `8199c048`. The Cloud Run dashboard and -alerts now cover route-template-scoped p99 skill latency, server errors, CPU, memory, and active -instance pressure. Alerts require five minutes above 80% p99 CPU/memory, more than 80% of the -configured instance ceiling, five-second interactive p99, or 30-second finalization p99; no bearer -URL or identifier becomes a metric label. - -Cloud PR `#347` passed both required checks and merged as `65320475`. It adds a six-hour aggregate -storage inventory job, fixed-namespace bytes and object-count metrics, overdue-quarantine and job -failure alerts, and storage dashboard panels. The monitor identity has only -`storage.objects.list`; its scheduler identity can invoke only that job. A reviewed targeted -staging apply added 18 observability resources, updated the existing dashboard, and replaced only -the inert metric-propagation timer. The post-apply targeted plan is zero-diff. A manual execution -completed successfully and emitted only aggregate published and quarantine measurements, with -zero overdue objects and no identifiers or paths. SQL remained `NEVER`/`STOPPED`, all three Relay -MIG targets remained zero, and production remained disabled and untouched. - -A separate read-only production prerequisite check confirmed that -`onorca-cloud-skill-packages` is currently unallocated, the production skill database and secret -names do not collide, and the intended API service and runtime identity already exist. Cloud Run -quota headroom exceeds the existing 20-instance API ceiling; V1 adds no database instance and -requires no quota increase. No production resource was planned or changed. - -Auth deploy run `31535179937` promoted `orca-cloud-auth-staging-00017-dug` with exact staging-only -GitHub OIDC constraints, healthy JWKS, and zero deployment-window errors. API run `31535438327` -promoted `orca-cloud-api-staging-00042-hef` with `authenticatedSmoke: true` and `skillSmoke: true`. -The ten-minute, non-refreshable smoke principals exercised artifact CRUD and skill upload, -finalize, immutable versions, recipient/outsider authorization, local and remote grants, rollback, -expiry, revocation, deletion, and signed-object cleanup. Structured skill logs exposed only -privacy-safe request metadata and produced zero sensitive-value matches. - -Cloud PR `#320` passed PR run `31564069382`, merged-`main` run `31564235724`, and a targeted -staging logging-exclusion plan of one addition, zero changes, and zero deletions. Guarded wake run -`31564370807` prepared staging. Deploy run `31564803943` promoted -`orca-cloud-api-staging-00051-yoq` at 100% traffic with immutable image digest -`sha256:511c0196511d2079bd9138092ad3cf065304b46a089b02d8df9318e0ae2e656a`. Candidate and canonical -smoke covered owner upload/finalize/share creation/inventory/revocation and anonymous preview, -local/remote download grants, version selection, expiry, uniform unavailable responses, package -deletion, and cleanup. Requests with no Authorization header and an invalid header returned the -same non-cached `404`; the serving revision produced zero errors, and Cloud Logging retained zero -per-link platform request logs. Cloud PR `#329` and desktop run `31569902499` subsequently passed -the complete browser-free publish/install/conflict/update/rollback/revoke/remove/delete journey. -Cloud PR `#330` merged as `8045c85dad`; deploy `31579844413` promoted revision -`orca-cloud-api-staging-00057-kat`, and recovery smoke `31580071168` proved generation-aware GCS -soft-delete restoration, transactional reference repointing, bearer download, and cleanup. -Guarded sleep `31580339694` restored SQL to `NEVER`/`STOPPED` and all Relay MIG targets to zero. -Load run `31585710645` then exercised 12 concurrent 30-skill bundles at roughly 3.95 MiB extracted -per bundle. Explicit saturation bounded ten requests while two completed; API, Cloud SQL, instance, -and latency metrics stayed far below their reviewed thresholds. PR `#335` made retry settlement -cleanup-safe, and run `31586684354` removed the exact failed-wave package set before two -manifest-proven quarantine generations were deleted with GCS preconditions. Guarded sleep -`31587083752` then returned SQL to `NEVER`/`STOPPED`, all three stable Relay MIGs to zero, and every -active Cloud Run revision minimum to zero. Guarded wake `31589384191` later restored only the two -configured Relay cells, and Auth deploy `31589963244` promoted -`orca-cloud-auth-staging-00025-zuz`. The browser-free physical `windows 2` staging journey then -passed publish, native Windows install, update, independent local version selection, rollback, -revocation preservation, removal, and Cloud deletion using encrypted credential run -`31591275227`; its ciphertext artifact and one-time local key material were deleted immediately. -Guarded wake `31603249983` later restored only the two configured cells. A disposable -no-service-account Ubuntu 20.04/glibc 2.31 VM passed the full live SSH staging lifecycle in 31.3 -seconds, including host-owned install, update, rollback, revocation preservation, removal, and -Cloud deletion. The encrypted credential artifact, one-time keys, Orca SSH target, remote install, -and VM were removed. Guarded sleep `31604391897` passed and independent reads confirmed SQL -`NEVER`/`STOPPED` plus all three Relay MIGs at stable target zero. Guarded wake `31605729090` then -enabled the same topology for an isolated headless paired host with a separate home/profile. The -full paired-runtime staging lifecycle passed in 12.5 seconds without local fallback, and its -environment, install, Cloud package, encrypted credential artifact, and one-time keys were -removed. Guarded sleep `31606600532` passed with the same independently verified low-cost state. -The owner accepted the additional live physical WSL Cloud journey as duplicate release evidence on -2026-08-12 after the combined 449-test Windows/WSL matrix and native-Windows Cloud lifecycle passed; -this does not collapse WSL semantics into macOS. Remaining staging gates are physical SSH -macOS/Windows and the time-gated quarantine lifecycle deletion. The Windows/WSL release matrix is -green. -Guarded wake retry `31630802215` succeeded before that decision. The first cleanup attempt -`31631288043` failed closed during Terraform provider download on an upstream `503`, before its -mutation step; guarded sleep retry `31631379891` succeeded. Independent reads verified SQL -`NEVER`/`STOPPED`, all three Relay MIGs stable and reached at target zero with no active actions, -and zero minimum instances on each active API, Auth, and Relay revision. -Guarded wake `31634214856` later restored the configured topology for an isolated native macOS -ARM64 OpenSSH target whose home, keys, and relay state lived under one temporary root. The full -publish/install/update/rollback/revoke/remove/delete staging lifecycle passed in 20.1 seconds -without local fallback. The run also corrected the test oracle to support valid absolute POSIX SSH -homes outside `/home`. The encrypted GitHub artifact was deleted immediately; the SSH service, -relay, install, temporary home, and one-time key material were removed. Guarded sleep -`31635145830` passed, and independent reads confirmed SQL `NEVER`/`STOPPED`, every MIG stable at -zero with no active actions, and API/Auth/Relay minimum scale zero. Supported Windows SSH and the -time-gated quarantine deletion remain open. - -Cloud PR `#355` removed internal publisher and organization identifiers from recipient responses, -passed code, Terraform, and PostgreSQL 16/17 checks, and merged as -`b3213bd34d1b224d8a3b11527eceaac883965400`. Guarded wake `31639920301` prepared staging. Deploy -`31640677572` promoted `orca-cloud-api-staging-00060-qay` at 100% traffic with immutable digest -`sha256:31cb0a91a7abf1f82e4de08bd31e98fbd71519adc731a005fc95f60480658f73`; authenticated and skill -candidate/canonical smoke passed. Its unrelated post-promotion storage-monitor image update lacked -`iam.serviceAccounts.actAs`; the prior monitor image remains serving and unchanged. Guarded sleep -`31640935616` passed in 8m51s; independent reads verified SQL `NEVER`/`STOPPED`, all three Relay -MIGs stable and reached at target zero, and the API at minimum scale zero while revision -`00060-qay` retained 100% traffic. - -Production deployment completed on 2026-08-12. The exact skill-only Terraform apply created the -private bucket, database, secret, least-privilege identities, request-log exclusion, metrics, -alerts, and dashboard with 40 additions and zero updates, replacements, or deletions. Disabled-route -bootstrap run `31648015091` migrated the schema and promoted `orca-cloud-api-00016-yem` while all -four controls remained false. Cloud PR `#357` enabled all four controls in one launch. Cloud PR -`#358` replaced the missing long-lived production smoke secret with an exchange restricted to the -exact production `main` workflow, GitHub environment, dispatch event, repository/owner IDs, and -audience; issued principals expire in ten minutes and have no refresh credentials. Auth run -`31649381596` passed candidate and canonical health. API run `31650178315` passed candidate and -canonical authenticated artifact plus skill lifecycle smoke, promoted -`orca-cloud-api-00022-maq` to 100% traffic, and completed the aggregate monitor image update. All -four skill controls are true, the serving revision reported zero deployment-window errors, and -both API and Auth health are green. - -Cloud PR `#359` codified the deploy identity's `roles/iam.serviceAccountUser` grant only on the -read-only skill storage monitor identity. Its exact production apply added one resource with zero -changes or deletions, and the repeat targeted plan was zero-diff. Manual execution -`orca-cloud-skill-storage-monitor-bmm4q` completed successfully. Cloud PRs `#360` and `#361` keep -Terraform ownership of the monitor shape while assigning its immutable image and Cloud Run client -metadata to the release workflow; the exact production monitor plan is zero-diff after the live -image advance. The development Orca instance -`skill-sharing-manual` now points at `login.onorca.dev` and `cloud-api.onorca.dev`; sign-in and the -desktop/real-host production lifecycle remain user-driven validation. - -A final renderer lifecycle review fenced managed-install inventory by request generation so a -slower previous machine cannot overwrite the newly selected machine's list. It also moved bundle -busy propagation into the initiating event instead of mirroring state through an effect. The -renderer skill/settings slice passed 122 tests across 18 files plus all Node/CLI/web typechecks, -focused lint, changed-code quality, max-lines, diff, and localization gates. - -## Research baseline - -The upstream assessment used `vercel-labs/skills` commit -`c6f69c631292444cc541ac6d91e2226b0ff247da`. - -The portable bundle assessment used -[`agentplugins/agent-plugins-spec`](https://github.com/agentplugins/agent-plugins-spec) commit -`bd383552095128f6effe895b9257cfd580a6d179`, specifically `spec/1.0.0.md`. The local reviewed clone -is `/Users/jinwoo/refs/misc/agent-plugins-spec`; the example repository is -`/Users/jinwoo/refs/misc/agent-plugins-example`. Orca independently implements the reviewed -behavior. The specification prose is CC BY 4.0 and its schemas/software are Apache 2.0, so Orca -will not copy the prose or vendor the schemas unless the corresponding notices are deliberately -handled. - -Useful upstream behavior: - -- A canonical `.agents/skills/` location. -- A data set of agent-specific global and project paths. -- Relative symlink handling, Windows junction behavior, and copy fallback. -- Archive path, entry-count, and extracted-byte limits. -- Tests for aliases, broken links, and provider-specific placement. - -Behavior Orca will not inherit: - -- Deleting the destination before the replacement is ready. -- Updating without checking whether installed files were modified. -- Direct read/modify/write lockfile updates without durable publication. -- Reinstalling the canonical directory once per selected agent. -- Treating an expiring signed artifact URL as package identity. -- Coupling installation to prompts, terminal output, telemetry, and process exits. - -Orca will not copy upstream source, tests, fixtures, or registry entries. Provider paths will be -implemented independently from official provider documentation and verified installations. Under -that constraint, Orca does not incorporate upstream copyrightable material and needs no upstream -attribution. Revisit this decision if implementation work ever proposes copying material. - -## Goals - -- Share one or many private skills with a teammate through one durable, intuitive link. -- Select and review large bundles, including groups of 30 skills, without repetitive dialogs. -- Install on the local computer or any compatible connected Orca runtime. -- Support global scope and folder-workspace/project scope. -- Work on macOS, Linux, and Windows. -- Preserve executable bits and all files in the skill package. -- Prevent archive traversal, symlink escapes, special files, case collisions, and resource - exhaustion. -- Never silently replace an unowned or locally modified skill. -- Recover from interruption during package download, extraction, placement, alias creation, - or provenance publication. -- Keep share authorization separate from short-lived blob access. -- Support immutable versions, updates, removal, and eventual team-wide reconciliation. -- Remain compatible with independently updated desktop clients and remote Orca servers. -- Treat ordinary folder workspaces as first-class; Git is not required. -- Preserve enough portable metadata for export, SSH/remote transfer, and offline validation. - -## Non-goals for the first release - -- Reimplementing all `skills` CLI source parsing and provider integrations. -- Supporting all upstream agent paths on day one. -- Publishing package bytes through a permanent or directly addressable blob URL. -- Automatically installing a shared skill on every organization machine. -- Merging local modifications with a newer shared package version. -- Treating installation as a security sandbox. A skill contains instructions and scripts and - must be presented to the user as code from its author. -- Shipping a full plugin runtime, MCP servers, hooks, processes, connectors, or portable - permissions. -- Requiring a provider-native plugin installer; unsupported agents still receive loose skills. -- End-to-end encryption from Orca Cloud operators in the initial trust model. Private means - unlisted and accessible through an active bearer link, with authenticated owner management. - Application-level package encryption can be added later without changing package identity. - -## Product experience - -### Sharing - -The existing Skills page has three header actions: **Share skills**, **Install from link**, and -**Manage**. **Share skills** enters selection mode across installed and workspace skills. Search -and filter changes retain selections; **Select all results** is explicit and Orca never silently -selects everything. The existing per-card share action remains a one-skill shortcut. - -Ineligible skills are disabled with a visible reason. Duplicate skill names must be resolved before -publication because they would collide in both the archive and destination roots. - -The review dialog shows: - -- Bundle name and selected skill list. -- Skill, file, and byte counts. -- Included scripts and executable files, with an expandable review for each skill. -- The signed-in profile that will own and manage the share. -- A clear warning that anyone with the unlisted link can inspect and install the bundle. -- Optional version label and release notes. - -After confirmation, Orca validates and packages the exact bytes shown in the preview. Progress is -named **Preparing skills**, **Uploading**, **Verifying**, and **Publishing link**. Completion returns -one durable URL such as: - -```text -https://app.orca.dev/skills/share/ -``` - -The URL identifies a revocable bearer share record, not a blob-storage object or permanent GCS -grant. Recipients do not sign in: possession of an active, unexpired link authorizes inspection and -a short-lived download grant. Revocation blocks future resolution and grants, while already -installed copies remain. Publishing, listing owned shares, version management, revocation, and -deletion still require the owner to sign in. - -### Installing - -Opening a deep link launches Orca and inspects the bundle without installing it. Preview identifies -the immutable version, release notes, skill count, scripts, and executable files. V1 deliberately -omits publisher identity from the recipient response and UI; internal creator and tenant records -remain private authorization and management data. The recipient can select all or a subset of the -contained skills. - -The destination picker supports the local machine, paired runtimes, WSL, and SSH, in global or -workspace scope. Before commit, the preview groups selected skills as new, unchanged, updates, or -conflicts and shows detected-agent coverage. Conflicts are handled per skill in one review surface, -not one dialog per conflict. **Keep local** is the default; replacement is explicit. The primary -action says **Install N skills**. - -Installation shows aggregate progress plus the current skill. Results are grouped as installed, -unchanged, kept local, and failed; failed items can be retried without repeating successful work. -An incompatible selected runtime receives an update-required result before transfer. - -**Manage** separates: - -- Installed bundles: update, rollback, install on another machine, inspect skills, and remove. -- Shared bundles: copy link, revoke, publish version, and delete the Cloud package. - -Settings includes a **Share Skills** section, parallel to **Artifacts**, explaining unlisted-link -behavior, retention, revocation, and the fact that installed copies are unaffected. It links to the -Skills page for publishing and management; it does not introduce a searchable catalog or a second -sharing workflow. - -Bundle update or removal never silently destroys locally modified skills. - -The UI must follow `docs/STYLEGUIDE.md` and use existing design tokens and shadcn primitives. - -## Target architecture - -```text -Selected skill folders - | - v -Package builder -- validates and creates immutable artifact - | - v -Orca Cloud -- private blob + version metadata + revocable bearer share - | - v -Download grant -- short-lived and scoped to one immutable digest - | - v -Destination runtime - | - +-- package ingress - +-- package inspection - +-- per-skill selection and install planning - +-- staged transaction - +-- provider placement reconciliation - +-- bundle and per-skill provenance publication - +-- post-install discovery -``` - -Cloud download, client-mediated transfer, and a local package file all terminate at the same -validated package-ingress boundary. Installation logic does not know which transport supplied -the bytes. - -## Package format - -Use a versioned tar archive so executable modes can be represented without a platform-specific -side channel. The extracted archive root is an Agent Plugins 1.0.0-compatible, skills-only plugin: - -```text -plugin.json -skills/ -├── skill-a/ -│ └── SKILL.md -├── skill-b/ -│ └── SKILL.md -└── ... -dev.orca.skill-sharing/ -└── manifest.json -``` - -Conceptual manifest: - -```ts -type SkillBundleManifestV1 = { - schemaVersion: 1 - packageId: string - versionId: string - bundleName: string - description: string - createdAt: string - skills: Array<{ - id: string - name: string - description: string - digest: string - files: Array<{ - path: string - size: number - executable: boolean - sha256: string - }> - }> - bundleDigest: string -} -``` - -Rules: - -- `packageId` is stable across versions; `versionId` identifies one immutable publication. -- `plugin.json` uses the canonical Agent Plugins 1.0.0 `$schema`, a valid portable plugin name, - bundle version, description, and optional standard metadata. Orca validates from locally owned - rules and never fetches the schema while loading. -- Every immediate `skills//SKILL.md` is required and must parse successfully. -- Each skill digest uses normalized relative paths, executable state, classification, and file - identity. The bundle digest commits to the ordered skill identities and their globally unique - archive paths. -- The installed folders receive only the selected children of `skills/`. -- Bundle identity, access, immutable versions, upload/download, update/rollback, and Cloud deletion - are bundle-level. Conflict handling, provenance, selection, local-modification protection, and - results are skill-level. -- Paths use `/` in the manifest and are converted with the executing host's path API. -- LF and CRLF non-executable text share one normalized package identity, while each immutable - archive retains and hashes its exact source bytes; executable and binary files always use exact - bytes for identity. -- Names are normalized once during publication. Installation never silently renames a conflict. -- The archive has no outer `bundle/` wrapper, so its root remains directly consumable by agents - that implement Agent Plugins. -- Orca-specific integrity and version data lives in `dev.orca.skill-sharing/manifest.json` rather - than a top-level custom field or GCS metadata alone. It therefore survives export, SSH/remote - transfer, and offline validation and can describe every skill and file. -- GCS custom metadata stays compact and operational. PostgreSQL stores only the package, share, - version, ownership, and lifecycle records needed to resolve a known link or manage an owned - bundle. Legacy ACL records may remain during migration but never gate bearer-link access. -- Orca constructs a fresh staging root. If imported content already contains - `dev.orca.skill-sharing`, Orca accepts only an appropriate recognized schema and rejects unknown, - malformed, or conflicting contents; it never overwrites the namespace silently. -- V1 rejects symlinks and special files. A later package-builder feature may safely dereference - internal links after proving they remain within the source root. -- Existing limits remain the starting contract: depth 16, 2,048 entries, 512 files, 4 MiB per - file, and 32 MiB total extracted bytes. Compressed download size also receives an explicit cap. -- Archive and manifest schema changes are additive or introduced as a new schema version. - -The package builder observes every selected source, copies it into a fresh private staging root, -observes the staged copies again, and only packages them if every identity agrees. The preview -shown to the user is bound to that final bundle digest. - -The original unpublished `manifest.json + skill/` staging format has no production compatibility -commitment. Orca will replace it before launch and migrate or discard staging-only records. Remote -bundle RPCs use a new `skills.install.bundle.v1` capability and additive methods so older peers do -not interpret bundle requests as single-skill requests. - -## GCP deployment plan - -### Existing production baseline - -Read-only inspection of the active `onorca-cloud` project on 2026-08-11 found: - -| Resource | Existing state relevant to skill sharing | -| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cloud Run | `orca-cloud-api`, `orca-cloud-auth`, `orca-cloud-relay`, and `orca-cloud-relay-fence` run in `us-central1` | -| API service | `orca-cloud-api` uses its own service account, scales from 0 to 20 instances, has a 300-second timeout, and already owns artifact-sharing endpoints | -| Object storage | `onorca-cloud-artifacts` is a `US` bucket with uniform bucket-level access, public-access prevention, exact CORS for `https://share.onorca.dev`, and seven-day soft delete | -| Database | `orca-cloud-auth-db` is PostgreSQL 17 in `us-central1`, regional/high-availability, with backups and point-in-time recovery | -| Database contents | The instance currently contains `orca_auth` and `orca_relay` databases; there is no skill database | -| IAM | `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` owns objects in the existing artifact bucket | -| APIs | Cloud Run, Cloud Storage, Cloud SQL Admin, IAM Credentials, Secret Manager, Logging, Monitoring, and Pub/Sub are enabled | -| Excluded services | Firestore and Cloud Asset Inventory are disabled; Cloud Tasks is not enabled | - -The GCP inspection was read-only. Commands that offered to enable disabled APIs were declined, and -no resources or IAM policies were changed. - -### V1 infrastructure decision - -Use the existing project, API service, authentication service, PostgreSQL instance, deployment -pipeline, and Terraform state. Add a dedicated package bucket and database instead of putting -private skill packages into the existing general artifact namespace. - -The V1 control plane remains in `orca-cloud-api`. Package validation is bounded and streaming, so a -separate worker service is not justified at the initial 40 MiB compressed-package ceiling. Protect -the existing API workload with a small per-instance package-finalization semaphore and return a -retryable response when that lane is full. - -Split package processing into a dedicated Cloud Run service later if finalize latency or CPU usage -interferes with artifact sharing. Do not reduce the entire API service's current concurrency merely -to accommodate one endpoint family. - -Firestore is not required. Skill metadata belongs in PostgreSQL beside the existing Orca Cloud -identity model. Cloud Tasks and Pub/Sub are not required for V1 because upload expiry is enforced -by a GCS lifecycle rule and package finalization stays synchronous and bounded. - -### Required GCP changes - -All production changes are declared in the existing Orca Cloud Terraform configuration. Do not -create durable resources manually with `gcloud`. - -| Change | Proposed production value | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| GCS bucket | `onorca-cloud-skill-packages`, subject to Terraform name validation | -| Location | `US`, matching existing shared-artifact storage; revisit per-region buckets when data residency becomes a product requirement | -| Access | Uniform bucket-level access and public-access prevention enforced | -| Soft delete | Seven days, matching the current artifact bucket | -| Object versioning | Disabled; package keys are immutable and GCS generations are recorded | -| Temporary lifecycle | Delete `uploads/` objects after one day | -| CORS | Exact approved Orca share/app origins; no wildcard origin | -| Database | `orca_skills` on `orca-cloud-auth-db` | -| Database principal | Dedicated `orca_skills_app` user with access only to `orca_skills` | -| Database secret | `orca-cloud-skills-database-url` in Secret Manager | -| Cloud Run service | Extend `orca-cloud-api`; mount the existing Cloud SQL instance and inject only the skill database secret | -| Package signer | Reuse `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` for V1 | -| Metrics/logging | Extend existing Cloud Logging and Monitoring configuration with skill endpoint dashboards and alerts | - -The bucket is separate because skill packages need different authorization, retention, signed -download, and lifecycle rules from public/shared artifacts. It also provides a clean IAM kill -switch without disrupting existing artifact links. - -### Bucket layout - -```text -gs://onorca-cloud-skill-packages/ - uploads//package.tar.gz - packages/v1/tenants//sha256///package.tar.gz -``` - -Rules: - -- `uploads/` is quarantine storage and is never downloadable through a share. -- Upload IDs are random, single-use, tenant-bound, and expire in PostgreSQL as well as GCS. -- Final package keys are derived from a one-way owner-tenant hash and the validated archive - SHA-256. Raw tenant identifiers never appear in object names. -- The database records the final object's GCS generation, compressed size, archive SHA-256, and - package digest. -- Final objects are never overwritten. Promotion uses a generation-match precondition. -- If a tenant's final key already exists, finalization verifies its archive SHA-256 and logical - package digest metadata before reusing it. -- Deduplication is tenant-scoped in both GCS and PostgreSQL, so another tenant's matching archive - cannot change publication timing or reveal that the digest already exists. -- GCS object versioning remains off; immutable keys plus stored generations prevent accidental - replacement, while seven-day soft delete provides operator recovery. -- Deleting the final database reference deletes the object only after a transaction proves no - package version still references it. GCS soft delete then supplies the recovery window. - -### Upload implementation - -Use a V4 signed POST policy rather than a signed unrestricted PUT. The POST policy enforces: - -- Exact quarantine object key. -- `application/vnd.orca.skill+tar+gzip` content type. -- A content-length range capped at 40 MiB. -- Required upload ID and expected archive SHA-256 metadata. -- A 15-minute expiration. - -The compressed limit is slightly above the 32 MiB extracted-file limit to allow tar metadata and -incompressible input. The package builder still enforces the lower extracted-content limits. - -Upload sequence: - -1. `orca-cloud-api` authenticates the user through the existing auth integration. -2. The API checks organization membership, package quota, per-user rate limits, and concurrent - upload limits. -3. It inserts a pending upload row and returns a signed POST policy for the unique quarantine key. -4. The client uploads directly to GCS; package bytes do not traverse Cloud Run. -5. The client calls finalize with the upload ID and package manifest identity. -6. The API reads GCS object metadata and rejects unexpected size, content type, key, generation, - or tenant binding. -7. The API streams the object once to calculate archive SHA-256 and validate the archive envelope, - manifest, path set, file limits, and package digest. It never buffers the whole package. -8. The API promotes the validated object to its immutable content-addressed key with an - if-absent generation precondition. -9. A PostgreSQL transaction publishes the immutable package version and marks the upload complete. -10. The quarantine object is deleted. The one-day lifecycle remains the backstop for abandoned - uploads. - -Run at most a small fixed number of finalizations concurrently per API instance. A full lane -returns `429` or `503` with `Retry-After`; it does not queue unbounded package bytes or work. - -### Download grants - -After resolving an active bearer share, `orca-cloud-api` creates a V4 signed GET URL with: - -- Exact final object key and stored generation. -- Five-minute expiration. -- Response content type and attachment filename. -- No list or write permission. - -The URL is the short-lived grant. The durable share URL is never signed directly and cannot access -GCS. Revocation cannot invalidate an already-issued GCS URL, so the five-minute lifetime is the -maximum revocation delay. - -The destination runtime accepts download URLs only from the configured GCS origin for the Orca -skill bucket, rejects credential-bearing redirects to any other host, enforces the expected byte -count while streaming, and verifies both archive SHA-256 and package digest before installation. - -### IAM - -Apply least privilege at the dedicated bucket and service-account level: - -- Grant the API service account `roles/storage.objectUser` on the skill-package bucket, not a - project-wide storage role. -- Grant the API service account `roles/cloudsql.client` for the existing instance. -- Allow the API service account to use IAM Credentials `signBlob` for itself so it can generate V4 - policies and URLs without a downloaded service-account key. The Terraform binding is scoped to - that service account. -- Grant Secret Manager accessor only for `orca-cloud-skills-database-url` and any future - skill-specific secret. -- Keep bucket IAM free of `allUsers` and `allAuthenticatedUsers`. -- Do not give the desktop, remote runtime, or user identity direct bucket IAM. They receive only a - time-bound signed operation after application authorization. -- Keep the existing deployment service account's impersonation/deploy permissions unchanged - except where Terraform needs to manage the new resources. - -No long-lived GCP key is shipped in Orca or stored on a remote runtime. - -### PostgreSQL schema - -Create migrations for: - -- `skill_packages`: stable bundle identity, owner organization, slug/name, creator, timestamps, - and deletion state. -- `skill_package_versions`: immutable version, package digest, archive SHA-256, GCS object key and - generation, byte and skill counts, release notes, creator, publication state, and the validated - bundle manifest needed to inspect a known share without downloading its private archive. -- `skill_package_uploads`: tenant/user binding, quarantine key and generation, expected identities, - expiration, finalization state, and failure category. -- `skill_package_acl`: legacy organization or user access records retained for migration until a - later schema cleanup; these do not authorize or deny bearer-share resolution. -- `skill_share_records`: unpredictable share ID, package ID, optional pinned version, expiration, - revocation state, creator, and timestamps. -- `skill_package_audit_events`: bounded security/lifecycle event metadata without package contents - or signed URLs. - -Required constraints and indexes include: - -- Unique package slug within its owning organization. -- Unique immutable version ID and package digest identity. -- Tenant-scoped archive identity and final GCS object key/generation pair. -- Share lookup by unpredictable ID and active/revoked state. -- No package, version, or contained-skill search/list index; share links are intentionally unlisted. -- Owner lookup for authenticated management operations. -- Pending-upload lookup by owner and expiration. -- Foreign keys that prevent removing a blob reference while a published version uses it. - -Share resolution and share-scoped download grants accept no user credential: an unpredictable, -active, unexpired share ID is the bearer credential. Anonymous requests receive the same -non-disclosing `404` for missing, expired, revoked, or deleted shares and remain subject to -distributed per-IP abuse limits. Package/version management, share creation, owner listing, -revocation, and deletion require an authenticated owner. - -### Cloud Run changes - -Add skill endpoints to `orca-cloud-api` and configure: - -```text -ORCA_SKILL_PACKAGE_BUCKET=onorca-cloud-skill-packages -ORCA_SKILL_PACKAGE_MAX_COMPRESSED_BYTES=41943040 -ORCA_SKILL_PACKAGE_UPLOAD_TTL_SECONDS=900 -ORCA_SKILL_PACKAGE_DOWNLOAD_TTL_SECONDS=300 -ORCA_SKILL_PACKAGE_FINALIZE_CONCURRENCY= -ORCA_SKILLS_DATABASE_URL= -``` - -Use the existing auth base URL and CORS-origin configuration. Do not add a second authentication -implementation for skills. - -Every API image rollout must stamp the complete Terraform-owned literal environment and exact -`ORCA_SKILLS_DATABASE_URL` secret reference in one no-traffic candidate. Before smoke or promotion, -compare the candidate with both the reviewed release values and the serving Terraform runtime -shape. Reject any unexpected secret or environment entry, changed service account, Cloud SQL -attachment, scaling bound, resource limit, volume, mount, port, or probe. This keeps ordinary image -deploys from clearing the skill database secret or silently detaching Cloud SQL. - -The API streams upload validation with fixed buffers under its existing 512 MiB memory limit. Load -test the finalize lane before choosing its fixed concurrency. Keep the current Cloud Run instance -scaling bounds initially, then adjust only from observed CPU, latency, database connection, and -error metrics. - -Recommended endpoint family: - -```text -POST /v1/skill-packages/uploads -POST /v1/skill-packages/uploads//finalize -POST /v1/skill-packages -GET /v1/skill-packages/ -GET /v1/skill-packages//versions -POST /v1/skill-packages//shares -DELETE /v1/skill-shares/ -GET /v1/skill-shares/ -POST /v1/skill-shares//download-grants -``` - -Finalize may create the package/version in one call; the separate package endpoint remains useful -when publishing a new version under an existing stable package identity. All mutating endpoints -accept idempotency keys. - -### CORS and public routing - -- Route the endpoint family through the existing Orca Cloud API hostname and deployment path. -- Add only exact production and approved development app/share origins to the skill bucket's CORS - policy. -- Allow `POST` for signed upload policy submission and `GET`/`HEAD` for signed download. -- Expose only required response headers such as `Content-Type`, `Content-Length`, `ETag`, - `x-goog-generation`, and `x-goog-hash`. -- Do not expose the GCS bucket through a public custom domain. -- Keep Cloud Run ingress and authentication consistent with existing API endpoints; package - authorization remains mandatory at the application layer. - -### Retention, deletion, and recovery - -- Pending upload database rows expire after 15 minutes and are safe to delete after one day. -- GCS deletes quarantine objects after one day regardless of database cleanup success. -- Published objects have no age-based deletion rule. -- Package deletion first marks metadata deleted and revokes shares, then deletes an object only - when no retained version references it. -- Seven-day GCS soft delete allows operator recovery from accidental object deletion. -- PostgreSQL backups and point-in-time recovery cover metadata; restoration procedures must restore - database rows and object generations consistently. -- Organization retention and legal deletion requirements override product rollback retention. - -### Monitoring and operational gates - -Add dashboards and alerts for: - -- Upload grant, finalize, share resolution, and download-grant request rate. -- Authorization denials and rate-limit outcomes. -- Finalize duration, semaphore saturation, archive rejection category, and digest mismatch. -- Cloud Run 5xx rate, CPU, memory, instance count, and request latency for skill routes. -- GCS quarantine bytes, published bytes, object count, and lifecycle deletion failures. -- PostgreSQL connection count, storage, query latency, migration status, and failed transactions. -- Signed URL creation failures and IAM Credentials errors. -- Orphan metadata/object reconciliation count. - -Budget alerts cover GCS stored bytes and egress, Cloud Run CPU/request growth, and Cloud SQL storage. -Logs contain package/version IDs and error categories, not manifests, filenames, share URLs, signed -policies, signed URLs, or ACL membership. - -### Provisioning and deployment sequence - -1. Confirm the existing Orca Cloud Terraform worktree and staging environment are current. -2. If there is no isolated staging GCP project, create or designate one before testing package - upload; do not exercise new lifecycle/IAM behavior first in `onorca-cloud` production. -3. Add the bucket, lifecycle, CORS, IAM, database, database user, secret, Cloud SQL attachment, API - configuration, dashboards, alerts, and budget thresholds to Terraform. -4. Run Terraform formatting, validation, and a reviewed plan. The expected production plan contains - no replacement of the existing artifact bucket, Cloud SQL instance, or Cloud Run services. -5. Apply the reviewed skill-only infrastructure plan in staging without shared-resource drift. -6. Deploy `orca-cloud-api` with all skill controls disabled. When the database secret is present, - startup applies PostgreSQL migrations without registering the skill routes. -7. Verify the migration-ready event, zero error logs, ordinary artifact behavior, and `404` skill - route boundary before changing any control. -8. Enable route registration for the staging identity and run upload/finalize/download tests, - including expired policies, anonymous bearer resolution, oversized uploads, corrupt archives, deduplication, - revocation, and object cleanup. -9. Run a staging desktop-to-runtime installation and verify logs contain no grants or private - contents. -10. Apply the reviewed production Terraform plan. -11. Run migrations and deploy the API with availability disabled. -12. After every release gate passes, enable the feature for all accounts in one launch. -13. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey, - retaining separate kill switches for upload grants, download grants, and remote installation. - -Production verification uses read-only commands such as: - -```bash -gcloud storage buckets describe gs://onorca-cloud-skill-packages --project=onorca-cloud -gcloud run services describe orca-cloud-api --region=us-central1 --project=onorca-cloud -gcloud sql databases list --instance=orca-cloud-auth-db --project=onorca-cloud -gcloud iam service-accounts get-iam-policy \ - orca-cloud-api@onorca-cloud.iam.gserviceaccount.com --project=onorca-cloud -``` - -Never print Secret Manager payloads or signed policies/URLs during verification. - -## Installation scopes and destinations - -### Global scope - -The canonical root is resolved on the destination host: - -```text -/.agents/skills/ -``` - -Provider-specific aliases are created only for detected agents that do not consume the canonical -root. - -### Workspace scope - -The canonical root is: - -```text -/.agents/skills/ -``` - -The workspace may be a Git worktree or a plain folder. Resolution uses the workspace identity -owned by the executing runtime; a client-provided path alone is not trusted for a remote target. - -Project installation does not create or modify a checked-in desired-state lockfile in V1. A -separate team-sync feature may later add an explicit, reviewable project manifest. - -### WSL scope - -WSL is a distinct execution target. Home resolution, path operations, package extraction, and -installation run inside the selected distro. The Windows client must not construct Linux home -paths or mutate the distro through translated Windows paths. - -### SSH-only scope - -An SSH target without an Orca runtime cannot execute the runtime RPC directly. Before GA, add a -host-side installation command invoked through the existing SSH execution and file-transfer -providers. The desktop uploads the immutable package through SFTP, invokes the bundled Orca -installer on the SSH host, and receives the same structured result as runtime RPC. - -If the required host component is unavailable, Orca reports the limitation and provides a local -package download or command; it never installs into the desktop's home as a fallback. - -## Provider destination strategy - -Orca owns a small data-only registry for agents Orca supports. Each entry describes: - -- Orca agent ID. -- Whether it reads the universal `.agents/skills` root. -- Global provider-specific path resolver when required. -- Project provider-specific path resolver when required. -- Detection evidence required before creating the path. -- Supported alias mechanism by platform. - -The registry is authored from official agent documentation and Orca's own verified installations. -Upstream may reveal that a compatibility case exists, but its implementation, path table, and -tests are not copied or transformed into Orca code. - -Policy: - -1. Always create one canonical copy. -2. Do nothing else for universal agents. -3. Create an alias for a detected non-universal agent. -4. On Windows, prefer a directory junction when supported. -5. Fall back to a verified independent copy only when aliases are unavailable. -6. Record every placement and its topology. -7. Never create configuration roots for dozens of undetected agents. -8. Never replace an unowned provider-specific copy without an explicit decision. - -Provider releases and official documentation are reviewed periodically. Orca's registry changes -only through normal review and platform tests; no upstream synchronization script owns it. - -The portable root does not imply every agent can install it as a native plugin. Cursor can consume -the root `plugin.json` package directly. Claude expects `.claude-plugin/plugin.json`; Codex and -ChatGPT expect `.codex-plugin/plugin.json`; the Codex IDE extension does not currently support -plugins. Orca therefore installs loose skills by default and may generate provider-native adapters -for detected compatible clients: - -- Cursor: use the portable package directly. -- Claude: generate `.claude-plugin/plugin.json` from validated bundle metadata. -- Codex/ChatGPT: generate `.codex-plugin/plugin.json` from validated bundle metadata. -- Other or unsupported agents: install the selected loose skills. - -Native plugin installation never becomes the only path. Provenance connects every loose installed -skill to its source bundle and immutable version. - -## Installer components - -Create narrow modules with explicit responsibilities: - -### Shared contracts - -- `src/shared/skill-package-manifest.ts`: bundle manifest, portable plugin manifest, and canonical - validation. -- `src/shared/skill-install-contract.ts`: bundle selection, per-skill preview, conflict, placement, - and result types. -- `src/shared/skill-install-capability.ts`: runtime capability name and compatibility message. - -### Main/runtime implementation - -- `src/main/skills/skill-package-creation.ts`: source observation, stable staging, and archive - creation. -- `src/main/skills/skill-package-extraction.ts`: bounded archive inspection and extraction. -- `src/main/skills/skill-package-download.ts`: grant validation, host allowlisting, streaming, and - compressed-byte limits. -- `src/main/skills/skill-install-destinations.ts`: global, workspace, WSL, and provider path - resolution. -- `src/main/skills/skill-provider-destinations.ts`: Orca-owned provider registry. -- `src/main/skills/skill-install-planner.ts`: current-state inspection and conflict decisions. -- `src/main/skills/skill-install-transaction.ts`: locking, staging, commit journal, rollback, and - recovery. -- `src/main/skills/skill-placement-reconciliation.ts`: aliases, junctions, and verified copy - fallback. -- `src/main/skills/skill-install-provenance.ts`: bounded receipts and recovery data. -- `src/main/skills/skill-install-service.ts`: orchestration and structured results. - -Reuse `observeSkillPackage` and the existing topology/freshness logic directly. Reuse the design -of plugin staging, provenance, lock serialization, and Windows rename retries, but do not import -plugin-domain modules into the skill domain. Promote a primitive only when both domains can name -and test the same contract accurately. - -### Cloud client - -- Skill upload grant creation and finalization. -- Authenticated share creation and revocation; anonymous share resolution. -- Download grant creation. -- Owned-package management and exact package/version lookup by ID; no browse or search API. -- Authenticated owner-only active-link inventory for cross-device copy and revocation; no public or - recipient inventory. -- Manager-only package details include ownership and active, unexpired share records; anonymous - recipients never receive management metadata. - -Exact files follow the repository that owns Orca Cloud APIs; desktop contracts stay provider -neutral. - -### Runtime and IPC - -- Extend the existing skill RPC registration with preview/install/update/remove methods. -- Register the same operations through desktop IPC. -- Route every request to the runtime that owns the selected machine and workspace. -- Keep package transfer separate from installation so direct download and chunked relay converge - on one staged file. - -### Renderer - -- Multi-select bundle share review and unlisted-link warning. -- Share completion dialog with copyable durable link. -- Active-link copy/revocation, immutable-version deletion, and Cloud-package deletion with explicit - confirmation and copy explaining that installed copies remain local. -- Settings → Share Skills owner inventory so newly published links remain manageable even when the - source skill has no managed-install receipt. -- Selective install preview with destination, scope, coverage, and per-skill conflict state. -- Aggregate install progress and grouped per-skill outcomes. -- Installed/shared bundle management, update, rollback, removal, and incomplete-coverage actions on - the Skills page. - -## Install request and result contract - -Conceptual request: - -```ts -type SkillBundleInstallRequest = { - operationId: string - package: { - packageId: string - versionId: string - bundleDigest: string - compressedBytes: number - } - selectedSkillIds: string[] - ingress: - | { kind: 'download-grant'; url: string; expiresAt: string } - | { kind: 'staged-upload'; uploadId: string } - | { kind: 'local-file'; path: string } - destination: - | { scope: 'global'; environmentId?: string } - | { scope: 'workspace'; worktreeId?: string; folderWorkspaceId?: string } - conflictResolutions?: Record< - string, - 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local' - > -} -``` - -`local-file.path` is accepted only across an in-process trusted boundary. It is not exposed as an -arbitrary remote RPC path. - -Conceptual result: - -```ts -type SkillBundleInstallResult = { - operationId: string - packageId: string - versionId: string - bundleDigest: string - status: 'complete' | 'partial' | 'failed' - skills: Array<{ - skillId: string - name: string - status: 'installed' | 'updated' | 'unchanged' | 'kept-local' | 'failed' - canonicalPath?: string - placements: Array<{ - provider: string - path: string - topology: 'canonical-copy' | 'provider-alias' | 'independent-copy' - status: 'installed' | 'unchanged' | 'skipped' | 'failed' - errorCategory?: string - }> - conflict?: { - kind: 'modified' | 'unowned' | 'external-link' | 'name-collision' - existingDigest?: string - } - errorCategory?: string - }> -} -``` - -The response never includes a download grant or credentials. - -## Installation algorithm - -### 1. Admission - -- Validate all request fields, package identifiers, selected skill IDs, and per-skill decisions. -- Resolve the destination through host-owned runtime state. -- Confirm the requested scope is writable and contained in an allowed home or workspace root. -- Reject an expired grant before network access. -- Deduplicate retries using `operationId`, package/version, selected skill set, and destination. - -### 2. Lock - -- Acquire locks for selected canonical destinations in deterministic skill-name order. -- Use an atomic lock-directory or exclusive-file creation supported on all target platforms. -- Record a random owner token and start time. -- Recover a stale lock only after validating its journal and owner liveness policy. -- Bound lock wait time and return a retryable busy result. - -An in-process promise chain alone is insufficient because the desktop, headless runtime, CLI, or -SSH helper may run concurrently. - -### 3. Ingress - -- Stream the archive into a bounded temporary file. -- For grants, require HTTPS when the configured cloud endpoint is HTTPS and restrict redirects and - final hosts to approved Orca storage origins. -- Abort when compressed bytes exceed the manifest contract. -- Hash while streaming and compare the archive identity supplied by Cloud. -- Delete partial bytes on cancellation, expiration, disconnect, or failure. - -### 4. Extract and inspect - -- Create extraction staging on the same filesystem as the canonical destination. -- Reject absolute paths, `..`, drive prefixes, NUL bytes, links, devices, FIFOs, sockets, - encrypted entries, duplicate normalized paths, and case-fold collisions. -- Enforce compressed, extracted, entry, file, depth, and per-file limits during extraction. -- Validate `manifest.json` before trusting file metadata. -- Observe `skill/` with `observeSkillPackage` and compare every file and the package digest. -- Parse `SKILL.md` and require its identity to agree with the package name. - -### 5. Inspect current state - -Classify the canonical destination and all requested provider placements. - -Canonical outcomes: - -- Missing: install is allowed. -- Same requested digest: no-op; continue to placement repair. -- Matches Orca receipt and remains unmodified: update is allowed. -- Differs from its Orca receipt: return a modified conflict. -- Exists without Orca provenance: return an unowned conflict. -- External or broken link: return a topology conflict. - -Provider outcomes: - -- Correct alias or matching Orca-owned fallback copy: no-op. -- Missing: create after canonical commit. -- Broken Orca-owned alias: repair. -- Unowned or modified placement: leave untouched and report incomplete coverage unless the user - explicitly chooses replacement. - -The preview and final install repeat current-state inspection. A state change between preview and -commit invalidates the preview and requires a new decision. - -### 6. Prepare commit - -- Copy extracted `skill/` into a hidden sibling staging directory. -- Preserve executable modes. -- Observe the copy and require the expected digest again. -- Write a durable transaction journal before moving the current destination. -- Keep the replacement and backup on the destination filesystem so directory renames do not cross - volumes. - -### 7. Commit canonical copy - -- If an existing destination is approved for replacement, rename it to the journal's backup path. -- Rename the verified staging directory into the canonical path. -- Use bounded Windows retry behavior for antivirus/indexer `EPERM`, `EACCES`, and `EBUSY` races. -- Observe the installed path and require the requested digest. -- On failure, restore the backup and retain enough journal state for startup recovery. - -Directory replacement is crash-recoverable rather than assumed to be a single atomic overwrite on -every platform. - -### 8. Reconcile provider placements - -- Create aliases relative to their real parent directory on POSIX. -- Use directory junctions with absolute targets on Windows. -- Detect parent directories that are already symlinked to canonical storage. -- If an alias mechanism fails, create and verify an independent copy when policy allows. -- Reconcile each placement idempotently. - -Canonical success is retained when one provider placement fails. The result is `partial`, and the -UI offers retry. Rolling back a valid universal installation because one optional alias failed -would make recovery less reliable. - -### 9. Publish provenance - -Write a bounded, versioned receipt outside the installed skill folder containing: - -- Package and version IDs. -- Expected package digest. -- Scope and destination identity. -- Canonical path identity. -- Provider placements and topologies. -- Previous version identity for interrupted-update recovery. -- Installation timestamp and Orca host/runtime identity. - -Do not store credentials, download URLs, share bearer values, or package contents. - -Serialize receipt updates, write them durably through a temporary file and rename, and support -reconstruction from per-install provenance if the aggregate index is corrupt. - -After the receipt is durable, mark the journal complete and delete transactional backups and -staging bytes. - -### 10. Verify and publish result - -- Run skill discovery on the destination target. -- Confirm the canonical skill and successful provider placements are observable. -- Return observed status and error categories rather than relying on process exit alone. -- Invalidate renderer skill caches and refresh the Skills page. - -## Update behavior - -1. Resolve the latest accessible immutable version from Cloud. -2. Compare its digest with the receipt. -3. Observe installed bytes before offering the update. -4. If bytes match the receipt, use the normal installation transaction. -5. If bytes were modified, offer: - - Keep local version. - - Publish local bytes as a new immutable version when exactly one non-missing Orca-managed - install matches the skill name and scope; Cloud rechecks ownership of that stable package ID. - - Replace and discard local changes after explicit confirmation. -6. Reconcile all recorded aliases and fallback copies. -7. Preserve the previous package version in Cloud; rollback is a normal install of that immutable - version. - -Do not invoke `npx skills update` for Orca-owned packages or write entries that cause the community -CLI to claim ownership of them. - -## Removal behavior - -1. Read Orca provenance and observe every recorded placement. -2. Remove only aliases that still point to the recorded canonical path. -3. Remove independent copies only when their bytes still match their recorded digest, unless the - user explicitly confirms discarding modifications. -4. Remove the canonical copy only when it is Orca-owned, unmodified, and no retained placement or - receipt depends on it. -5. Publish the provenance change durably. -6. Leave an unowned or changed destination untouched and report what remains. - -Cloud package deletion and local removal are separate actions. Revoking a share prevents new -downloads but does not silently delete already installed local files. - -## Remote, SSH, and mixed-version compatibility - -Add a static runtime capability: - -```text -skills.install.v1 -``` - -Clients check it before sending installation RPCs. Older servers continue supporting discovery -but do not show remote install actions. A missing capability produces an update-required message. - -Adding new RPC methods and a static capability does not require a runtime protocol bump. New -fields remain optional until all supported peers understand them. No new terminal stream opcode is -needed. - -Recommended RPC surface: - -- `skills.install.preview` -- `skills.install.beginUpload` -- `skills.install.uploadChunk` -- `skills.install.commitUpload` -- `skills.install.cancelUpload` -- `skills.install.execute` -- `skills.install.update` -- `skills.install.remove` - -Upload sessions are bounded by count, bytes, idle lifetime, and chunk size. Offsets are monotonic; -retries either repeat an acknowledged chunk idempotently or restart the upload. Disconnect and -cancellation release staging bytes. Installation RPC retries are also bounded. Because operation -IDs make commit idempotent, a direct install can converge after its response is lost; a staged -install rebuilds the upload before retrying because the first host attempt may already have -consumed it. - -Client cancellation is carried through the local runtime-call queue and every paired request -transport. A queued call is removed before it starts; an in-flight one-shot request closes only its -socket; cached and shared-control requests release only their own request state so unrelated users -of the connection continue. Late responses for cancelled shared requests are retired and ignored. -This is local transport behavior and does not change the mixed-version wire contract; the existing -operation-ID cancellation RPC remains responsible for converging host-side installation work. - -SSH Relay errors retain JSON-RPC numeric codes on the wire. Known skill failures add optional, -schema-validated `error.data`; arbitrary handler data is never published. This is additive for -mixed versions and gives SSH the same stable error categories as native and paired installation -without exposing package contents or credentials. - -The runtime that executes installation owns: - -- Home and config-directory resolution. -- Workspace/folder identity resolution. -- WSL distro selection. -- Agent detection and provider paths. -- Filesystem mutation and recovery. - -The calling client owns: - -- User authentication and share authorization. -- Destination selection. -- Obtaining a grant or providing package chunks. -- Rendering preview, progress, conflicts, and results. - -## Security and privacy controls - -The detailed threat register and residual release gates live in -[`docs/reference/agent-skill-sharing-threat-model.md`](./reference/agent-skill-sharing-threat-model.md). - -- Treat `SKILL.md` and packaged scripts as executable code for trust messaging. -- Show version, digest, file summary, and executable files before install without exposing internal - publisher or organization identifiers. -- Require the exact active bearer share ID for recipient resolution and every download grant; - require authentication for publication and owner management. -- Bind grants to one package version, digest, maximum byte count, and short expiration. -- Validate grant and redirect hosts to prevent server-side request forgery. -- Never execute package scripts during installation. -- Reject links and special files before they can be published or extracted. -- Keep package staging owner-only where platform permissions support it. -- Avoid logging file contents, share tokens, signed URLs, or organization-private names. -- Audit authorization and lifecycle events without storing skill contents in telemetry. -- Apply organization deletion and retention policy to metadata and blobs. -- Rate-limit uploads, downloads, share resolution, and remote transfer sessions. -- Use constant-shape authorization failures so private package existence is not disclosed. - -## Failure and recovery model - -The transaction journal records enough state to classify interrupted operations: - -- Download only: delete partial archive. -- Extracted but not committed: delete staging. -- Existing destination moved to backup: restore backup. -- New destination placed but receipt absent: verify new digest, then either finish receipt - publication or restore backup according to journal state. -- Receipt published but journal incomplete: verify receipt and installed digest, then finalize. -- Alias reconciliation interrupted: preserve canonical installation and retry reconciliation. - -Recovery runs before a new operation for the same destination and during bounded startup cleanup. -It never removes an unknown path based only on a filename pattern; the journal owner token, -destination containment, and expected identities must all agree. - -## Testing strategy - -### Package tests - -- Deterministic manifest and digest generation. -- Source changes during packaging. -- CRLF/LF identity behavior. -- Executable mode preservation. -- Missing or malformed `SKILL.md`. -- Excessive depth, entries, files, individual size, total size, and compressed size. -- Traversal, absolute paths, Windows drive paths, Unicode/case collisions, duplicate paths, - symlinks, hardlinks, devices, FIFOs, sockets, and encrypted entries. -- Truncated and checksum-invalid archives. -- Invalid gzip bytes with a stable non-retryable archive failure. - -### Transaction tests - -- Fresh install, identical reinstall, clean update, and explicit replacement. -- Modified and unowned conflicts. -- Existing canonical or provider paths that are files, directories, links, or broken links. -- Failure before and after every journal transition. -- Backup restoration and idempotent recovery. -- Concurrent desktop, runtime, and CLI attempts. -- Cancellation during download, extraction, copy, commit, and alias reconciliation. -- Antivirus-style Windows rename contention. -- Permission failures and read-only destinations. -- Permission and disk-capacity failure injection that preserves the prior installed version. -- Alias failure with verified copy fallback. -- Independent copy drift during update and removal. -- Lost final-response retry for direct installs and staged-transfer rebuild before retry. - -### Target matrix - -- macOS ARM64 and x64 behavior where available. -- Linux at the supported Ubuntu 20.04/glibc 2.31 floor. -- Windows with junction success, junction denial, copy fallback, and long paths. -- WSL with multiple distros and distro-owned home resolution. -- Local Git worktree and plain folder workspace. -- Paired remote runtimes in both client-newer and server-newer combinations. -- SSH-only macOS/Linux/Windows targets where supported by existing providers. -- Remote target with no outbound Cloud connectivity using chunked transfer. - -### Cloud tests - -- Anonymous active-link resolution, expiry, revocation, and non-disclosing missing-link failures. -- Owner authentication for package and share management. -- Upload finalization digest mismatch. -- Blob deduplication without cross-tenant information leakage. -- Quota and rate-limit behavior. -- Durable share resolving to the intended immutable version. -- Version update and rollback. - -### End-to-end journeys - -1. Share on machine A, install globally on machine B, launch a detected agent, and discover the - skill. -2. Share on machine A, install into a folder workspace on a connected remote runtime, and discover - it only in that workspace. -3. Modify the installed copy, publish an update, and prove Orca refuses silent replacement. -4. Disconnect during commit, reconnect, recover, and obtain either the complete old or complete - new version. -5. Revoke a share, prove new installation fails, and prove an existing local installation remains. - -## Observability - -Record bounded operational metrics: - -- Package byte/file counts and stage durations. -- Download versus client-mediated transfer selection. -- Install outcomes by category. -- Conflict categories. -- Alias, junction, and copy-fallback rates. -- Recovery and rollback counts. -- Runtime capability absence. -- Error categories by operating system and target kind. - -Do not record skill contents, file names beyond approved aggregate categories, private share URLs, -download grants, access lists, or raw local paths. - -User-facing logs show phase, destination label, placement outcome, and actionable error. Sensitive -network values are redacted at creation rather than scrubbed after logging. - -## Delivery phases - -### Phase 0: contracts and vertical spike - -Deliver: - -- Package manifest schema. -- Local package builder and bounded extractor. -- A programmatic install call from a validated staging directory into a temporary canonical root. -- Fresh install and modified-conflict tests on macOS, Linux, and Windows CI. -- Decision record confirming upstream is behavioral-reference-only and no material is copied. - -Exit criteria: - -- No `npx` or Node installation dependency outside Orca's own runtime. -- Same bytes produce the same digest on every target platform. -- Invalid archive classes fail before destination mutation. - -Estimate: 2–3 engineer-days for the spike, followed by design review. - -### Phase 1: production local installer - -Deliver: - -- Cross-process lock. -- Durable journal and recovery. -- Canonical install, update, and removal. -- Provenance receipts and aggregate index recovery. -- Provider registry for Orca's primary detected agents. -- POSIX aliases, Windows junctions, and verified copy fallback. -- Structured preview/result contracts. -- CLI-only developer harness for deterministic integration tests. - -Exit criteria: - -- Failure injection at every commit boundary preserves either the previous or requested complete - package. -- Local modifications are never silently replaced. -- Folder workspaces and global scope pass the platform matrix. - -Estimate: 5–8 engineer-days. - -### Phase 2: private Cloud sharing - -Deliver: - -- Reviewed Terraform for the dedicated private GCS bucket, lifecycle, CORS, bucket-scoped IAM, - service-account signing, `orca_skills` database, database secret, Cloud SQL attachment, - monitoring, and budgets. -- PostgreSQL migrations for package, version, upload, share, ownership, and audit records, with - legacy ACL storage tolerated during migration. -- Upload/finalize APIs using bounded V4 signed POST policies and private content-addressed GCS - objects. -- Version, bearer-share, revocation, five-minute download grant, quota, and audit behavior. -- Share preview and owner-management UI. -- Install preview and local-machine installation UI. -- Durable links and short-lived grants. -- Update availability and version rollback. - -Exit criteria: - -- A copied active durable link can be inspected and installed without recipient sign-in. -- Revocation immediately blocks new grants. -- The installed receipt identifies the immutable package without persisting a grant. -- Production provisioning changes are Terraform-owned and do not replace existing artifact, - Cloud SQL, or Cloud Run resources. - -Estimate: 1–2 engineer-weeks across desktop and Cloud work. - -### Phase 3: paired Orca runtime installation - -Deliver: - -- `skills.install.v1` capability. -- Runtime preview/install/update/remove methods. -- Direct runtime download. -- Bounded client-mediated upload fallback. -- Destination-machine and remote-workspace selection. -- Mixed-version UI and compatibility tests. - -Exit criteria: - -- Installation executes on the selected host and uses that host's home, workspace, and detected - agents. -- Older hosts are not called and receive a clear update-required state. -- A host without outbound internet installs through the authenticated chunked transfer path. - -Estimate: 4–7 engineer-days. - -### Phase 4: WSL and SSH completion - -Deliver: - -- Distro-owned package ingress and installer execution. -- SSH package upload and host-side structured installer invocation. -- Cross-target cancellation and cleanup. -- WSL/SSH provider detection and path coverage. -- Real-host end-to-end coverage. - -Exit criteria: - -- No desktop path is substituted for a WSL, SSH, or runtime path. -- SSH and WSL return the same result contract as native installation. -- Failure and cancellation leave no untracked staging bytes outside bounded recovery retention. - -Estimate: 4–7 engineer-days, depending on host-helper availability. - -### Phase 5: multi-machine management and reconciliation - -Deliver: - -- “Install on another machine” and multi-machine progress. -- Optional desired-version policy for selected personal or organization machines. -- Drift and missing-install reconciliation. -- Explicit project desired-state manifest if validated by product usage. -- Direct machine-to-machine transfer as an alternative to Cloud persistence if required. - -Exit criteria: - -- Reconciliation remains opt-in and never overwrites local modifications. -- Offline machines converge after reconnect without sharing durable credentials. -- Organization removal, user departure, and package retention have documented semantics. - -Estimate: separate product milestone after first-release usage data. - -## Release gates - -Before enabling Cloud or remote installation by default: - -- Threat-model review covers package ingestion, grants, SSRF, archive extraction, local path - containment, and instruction/script trust. -- Cross-platform CI covers the package and transaction suites. -- Real Windows validates junction and copy fallback. -- Real WSL and SSH validate host-owned paths. -- Mixed-version remote tests cover old client/new server and new client/old server. -- Download/upload cancellation and app/runtime crash recovery are exercised. -- Telemetry and diagnostic bundles are verified not to contain grants or private contents. -- Share deletion, revocation, organization departure, and retention behavior are documented. -- The UI identifies the author and executable content before installation. -- A kill switch can disable new Cloud grants and remote installs without affecting discovery or - already installed skills. - -## Effort summary - -For one engineer familiar with Orca: - -| Scope | Estimate | -| ----------------------------------- | -------------------------------------------: | -| Disposable canonical-copy prototype | 1–2 days | -| Production local installer | 5–8 days | -| Cloud sharing and local install UX | 1–2 weeks | -| Paired runtime support | 4–7 days | -| WSL and SSH completion | 4–7 days | -| Polished first release | Approximately 3–5 engineer-weeks | -| Full community CLI parity | 6–10 weeks plus ongoing registry maintenance | - -The recommended first release ends after Phase 4. Phase 5 should follow observed demand rather -than delaying private sharing for organization-wide policy features. - -## Implementation principles - -- One validated installer core, regardless of source or destination transport. -- One canonical copy, with provider placements reconciled separately. -- Immutable Cloud versions and mutable local installations are distinct concepts. -- An unpredictable durable share ID is a revocable bearer credential, not a permanent blob URL. -- Installed bytes, not child-process exit codes, determine success. -- Unknown or modified local state fails closed. -- Remote hosts own their paths and mutations. -- Upstream behavior informs compatibility but does not define Orca's internal architecture. diff --git a/docs/ai-vault-process-isolation-plan.md b/docs/ai-vault-process-isolation-plan.md deleted file mode 100644 index f9be048a12a..00000000000 --- a/docs/ai-vault-process-isolation-plan.md +++ /dev/null @@ -1,596 +0,0 @@ -# AI Vault process isolation architecture and implementation plan - -Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete. - -Last updated: 2026-08-09. - -## Decision summary - -AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary. - -The target has three rules: - -1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process. -2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data. -3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer. - -This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified. - -The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal. - -## Goals - -- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host. -- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active. -- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time. -- The service can be restarted without restarting the app, runtime, relay, or terminals. -- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible. -- Mixed-version remote peers continue to work. -- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services. - -## Non-goals - -- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity. -- Rewriting the scanner or changing session discovery semantics during the process migration. -- Moving xterm into another renderer. -- Adding a process per window, worktree, repository, or SSH request. -- Removing the old-relay compatibility fallback in the same change. -- Making pagination a public wire dependency before mixed-version fallback behavior exists. - -## Current architecture and coupling - -| Path | Current execution | Remaining coupling | -| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main | -| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host | -| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process | -| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay | -| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm | - -The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API. - -The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign. - -## Feature-parity contract - -The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics. - -| Capability | Required behavior after migration | Owner | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters | -| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today | -| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection | -| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router | -| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them | -| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation | -| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing | -| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate | -| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate | -| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result | -| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration | -| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success | -| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling | -| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports | - -Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow. - -No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list. - -## Target topology - -```text -Desktop / runtime host - - Integrated renderer - ├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon - └─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process - │ - └─ host merge + compatibility routing - -SSH execution host - - Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar - └────────────────── PTY handler/event loop -``` - -The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar. - -## Process ownership - -- Desktop: one Vault service per Electron main process, shared by every window and workspace. -- Orca runtime: one Vault service per runtime host process, shared by all connected clients. -- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests. -- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration. -- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop. - -## Service responsibilities - -The service owns: - -- Source discovery and filesystem metadata reads. -- Transcript streaming and parsing for all supported agents. -- OpenCode SQLite reads and its nested worker lifecycle. -- In-memory and persisted session parse caches. -- The bounded title index and transcript title resolution. -- On-demand Claude/OMP subagent listing and full first-user-prompt extraction. -- Scan parse concurrency and cooperative cancellation. -- Result construction, deduplication, sorting, and internal paging. -- Per-request metrics that contain counts and timings, not session content. - -The router owns: - -- Electron IPC, runtime RPC, or relay method registration. -- Authentication and connection ownership already enforced by that transport. -- Public argument validation and execution-host selection. -- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration. -- Multi-host result merging. -- Request coalescing and mapping caller cancellation to the service request. -- Validation of service responses before publishing them across a public boundary. -- Compatibility fallback selection. -- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation. - -The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication. - -## Internal process protocol - -Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol. - -Parent to service: - -```ts -type ServiceRequest = - | { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions } - | { type: 'request'; id: number; operation: 'scan'; options: ScanOptions } - | { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] } - | { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest } - | { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest } - | { type: 'invalidate'; paths: string[]; generation: number } - | { type: 'cancel'; id: number } - | { type: 'shutdown' } -``` - -Service to parent: - -```ts -type ServiceResponse = - | { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities } - | { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics } - | { type: 'result'; id: number; operation: 'titles'; value: TitlesResult } - | { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult } - | { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult } - | { type: 'invalidated'; generation: number } - | { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean } - | { type: 'fatal'; code: ServiceErrorCode; message: string } -``` - -Protocol rules: - -- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch. -- Use monotonically increasing safe-integer request IDs scoped to the child lifetime. -- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth. -- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes. -- Coalesce equivalent scans. Replace an older queued background scan with the newest one. -- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does. -- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy. -- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck. -- Continue returning the last good renderer result when a refresh fails. -- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state. -- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented. - -The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers. - -## Lifecycle and supervision - -### Startup - -- Start lazily on the first scan or title request. -- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader. -- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows. -- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready. -- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child. - -### Steady state - -- Reuse the process and caches across calls. -- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency. -- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal. -- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive. -- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper. - -### Faults and restart - -- Reject the active request on exit, disconnect, malformed protocol, or timeout. -- Retry queued work in a new process only when it is safe and has not been cancelled. -- Back off at 250 ms, 1 second, then 5 seconds. -- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt. -- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely. - -## Resource and quality-of-service policy - -The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness. - -- CPU: below-normal process priority, scan concurrency eight, one scan at a time. -- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry. -- Queue: 16 calls, coalescing, background replacement, explicit priority. -- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior. -- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve. -- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending. -- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap. - -Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation. - -## Renderer publication - -`ai-vault-session-refresh.ts` should stop applying a completed result immediately. - -Add `ai-vault-session-publication-gate.ts` with this behavior: - -1. Cache the validated result immediately so another caller can reuse it. -2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`. -3. Otherwise retain only the newest pending result and wait for quiet. -4. Publish after 1 second even if input continues, using a transition and one bounded page. -5. Cancel a pending publication when the scope, host, or component request token changes. - -Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel. - -A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization. - -## Desktop and runtime implementation - -Create these concrete modules: - -- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown. -- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy. -- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions. -- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution. -- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes. -- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy. - -Reuse the scanner and cache modules without moving their business logic. Replace imports in: - -- `src/main/ai-vault/cached-session-list.ts` -- `src/main/ai-vault/session-title-resolver.ts` -- `src/main/ipc/ai-vault-subagent-list.ts` -- `src/main/ai-vault/session-first-user-prompt-read.ts` -- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete -- `src/main/ipc/ai-vault.ts` -- `src/main/runtime/rpc/methods/ai-vault.ts` - -Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period. - -Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration. - -## SSH relay sidecar implementation - -Create: - -- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader. -- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter. -- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports. - -Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process. - -`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness. - -If the sidecar cannot start or crashes: - -- Return a normal `AiVaultListResult` containing a host issue and no new sessions. -- Do not scan inline in the relay. -- Keep all PTYs, reconnect state, and other relay methods available. -- Allow the next manual refresh to cross the restart circuit breaker once. - -Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals. - -## Remote compatibility - -The first implementation adds no public method, stream opcode, required field, or changed result meaning. - -- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar. -- New desktop with current relay: calls the same methods and receives the same result shape. -- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback. -- Old desktop with new relay: unknown internal sidecar details never cross the wire. - -Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability. - -The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision. - -## Folder workspaces, WSL, and paths - -- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata. -- Continue using `path.join` and the existing host-platform path adapters. -- Keep Windows batch and shell selection unrelated to terminal-shell preference. -- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows. -- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths. -- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript. -- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected. - -## Security boundary - -The service is a performance and reliability boundary, not a privilege boundary. - -- It has the same OS user and must access the same agent stores. -- Do not expose its IPC channel to renderer code or the network. -- The parent passes validated options; the service validates again before filesystem access. -- Do not log prompts, titles, transcript paths, environment values, or raw service messages. -- Preserve symlink and allowed-root checks for title reads. -- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further. - -## Packaging - -Desktop packaging changes: - -- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`. -- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`. -- Reuse the existing unpacked entry-path pattern used by other forked processes. -- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down. - -Relay packaging changes: - -- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture. -- Include it in `.version` content hashing and deploy completeness assertions. -- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together. -- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier. - -## Observability - -Add spans and counters for: - -- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit. -- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls. -- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count. -- Parent/child serialization duration and serialized bytes. -- Child RSS and heap-used snapshots at request completion. -- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count. -- Relay sidecar failures separately from PTY and relay-loop health. - -Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs. - -## Verification matrix - -The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable. - -| Layer | Required coverage | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts | -| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ | -| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush | -| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown | -| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP | -| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior | -| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions | -| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state | -| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency | -| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata | - -The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals. - -## Failure behavior - -| Failure | User-visible Vault behavior | Terminal behavior | -| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | -| Service fails to start | Keep last result and show a retryable host issue | Unchanged | -| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged | -| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged | -| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged | -| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged | -| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available | -| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged | -| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains | - -## Benchmark methodology - -The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home. - -Each iteration: - -1. Keeps the Vault panel mounted in both arms. -2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home. -3. Alternates arm order to reduce warm-up bias. -4. Runs paced raw-mode terminal echo typing as the control. -5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment. -6. Verifies the newest seeded title is visible, proving the full scan and UI path completed. - -The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key. - -Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory. - -## Current measurements - -Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work. - -Pre-implementation parity baseline: - -- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts. -- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests. -- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes. - -### Representative pass - -Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data. - -| Metric | Control | Refresh active | -| ------------------------------ | -------------------: | ------------------: | -| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms | -| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms | -| Worst timer drift | 14.2 ms | 24.5 ms | -| Worst frame gap | 9.4 ms | 24.9 ms | -| Renderer long tasks over 50 ms | 0 | 0 | -| Missing echoes | 0 / 300 | 0 / 300 | -| Refresh duration p50 / max | — | 103.5 / 204.2 ms | - -There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration. - -### Stress pass - -Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data. - -| Metric | Control | Refresh active | -| ------------------------------ | ------------------: | -------------------: | -| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms | -| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms | -| Worst timer drift | 9.3 ms | 43.6 ms | -| Worst frame gap | 9.4 ms | 50.0 ms | -| Renderer long tasks over 50 ms | 0 | 0 | -| Missing echoes | 0 / 240 | 0 / 240 | -| Refresh duration p50 / max | — | 276.3 / 453.5 ms | - -The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer. - -### Measurement limits - -- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior. -- The benchmark covers the local service path, not the currently unisolated SSH relay path. -- Raw shell echo is a stable latency probe, not a full-screen TUI workload. -- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost. - -## Post-implementation A/B - -Run the same build with the rollout switch off and on, alternating launch order: - -```bash -ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control -ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment -``` - -Run at least five representative and five stress passes on: - -- macOS Apple Silicon. -- Windows x64 with a folder workspace and a WSL-backed source present. -- Ubuntu 20.04-compatible x64 packaging/runtime. -- A 4-core or smaller machine or constrained CI runner. - -Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy. - -## Implemented result - -The implementation now matches the target topology: - -- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback. -- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry. -- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue. -- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes. -- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second. -- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker. - -Local validation on 2026-08-09: - -- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully. -- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane. -- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone. -- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled. - -Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence. - -| Metric | Worker control | Worker refresh | Process control | Process refresh | -| --------------------- | -------------: | -------------: | --------------: | --------------: | -| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms | -| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms | -| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms | -| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms | -| Long tasks over 50 ms | 0 | 0 | 0 | 0 | -| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 | -| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms | - -The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness. - -Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable. - -## Acceptance criteria - -Correctness: - -- Zero missing terminal echoes. -- The latest seeded Vault session is visibly published in every treatment arm. -- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner. -- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics. - -Performance: - -- Treatment parse p95 is no more than 10 ms above its paired control. -- Treatment render p95 is no more than 15 ms above its paired control. -- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms. -- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run. -- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes. - -Isolation: - -- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon. -- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods. -- The service respects queue, timeout, idle, and heap bounds. -- Packaged app and relay artifacts contain and launch the correct entry for every supported platform. - -## Phased implementation plan - -### Phase 0 — measurement harness (completed in this change) - -- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes. -- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`. -- Capture representative and stress baselines. - -Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes. - -### Phase 1 — desktop and runtime service behind a switch - -- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor. -- Reuse current scanner/cache implementations. -- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled. -- Keep the worker path as the disabled-switch fallback. -- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution. -- Add E2E fault injection that kills the service during active terminal typing. - -Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets. - -### Phase 2 — make the desktop/runtime process the default - -- Enable the process by default in development, E2E, and canary builds. -- Collect process RSS, restart, timeout, and latency telemetry without content. -- Validate Windows, Linux, WSL, and constrained-runner results. -- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle. - -Exit: no unexplained crash or timeout increase and all acceptance gates green. - -### Phase 3 — SSH relay sidecar - -- Add the relay sidecar entry and supervisor. -- Remove scanner/parser/cache imports from the relay handler. -- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test. -- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests. -- Make inline relay scanning impossible; sidecar failure returns a host issue. - -Exit: remote typing and fault gates green; current public relay methods unchanged. - -### Phase 4 — renderer quiet publication - -- Add the shared quiet-publication gate and React transition. -- Retain only the newest pending result per scope/host. -- Add supersession, starvation timeout, unmount, and rapid-host-switch tests. -- Re-run the Electron benchmark with large result counts and active filtering/grouping. - -Exit: no Vault-attributable renderer long task over 50 ms in the representative gate. - -### Phase 5 — hardening and cleanup - -- Remove the worker implementation after one stable release if the kill switch was unused. -- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry. -- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate. -- Document operational recovery and service diagnostics. - -Exit: kill switch and dead worker code removed only after evidence supports it. - -## Rollback - -- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data. -- Renderer: disable quiet publication and apply the validated complete result directly. -- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails. -- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion. - -## Confidence and remaining questions - -Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation. - -The remaining implementation questions are deliberately narrow: - -1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on. -2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry. -3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets. -4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop. diff --git a/docs/automations-all-hosts-design.md b/docs/automations-all-hosts-design.md deleted file mode 100644 index c186cc67c13..00000000000 --- a/docs/automations-all-hosts-design.md +++ /dev/null @@ -1,530 +0,0 @@ -# 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/docs/mobile-relay-ux-findings.md b/docs/mobile-relay-ux-findings.md deleted file mode 100644 index 3fbbd1419be..00000000000 --- a/docs/mobile-relay-ux-findings.md +++ /dev/null @@ -1,442 +0,0 @@ -# Mobile Relay UX — Investigation Findings & Fix Plan - -Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over -the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes -desktop or relay-server code. All file references are in `mobile/` of this worktree. - -## 1. Symptom → root-cause summary - -| # | Symptom | Root cause (verified) | -|---|---------|----------------------| -| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` | -| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication | -| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible | - -## 2. Verified end-to-end causal chains - -### S1 — Resume dead-ends on the host index page - -1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'` - (`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card - inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift - under the thumb. -2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting - `/h/[hostId]/session/[worktreeId]`. -3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves - the push to the host stack's **index route with no `hostId` param**. This exact failure mode is - documented twice in-repo ("cold Expo deep links resolve to index" — - `src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the - root cause named by PR #12001. -4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`. - `HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns - `state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**. -5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and - every fetch no-ops on `!client || connState !== 'connected'` - (`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are - static toolbar UI, so the page looks "real" but dead. -6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by - navigation history. - -Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not -the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with* -a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than -it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration -(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`), -so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it. - -**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through -the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`. -Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation -(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4), -catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9). - -### S2 — grey blink when focusing a healthy relay host - -1. Every focus of the host screen fires `notifyForeground()` - (`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps). -2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)` - (`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe - (`src/transport/mobile-endpoint-lifecycle.ts:62-64`). -3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls - `RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`). -4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session** - (`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns - unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink. -5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes - `'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot - (`src/components/StatusDot.tsx:11`), worktree queries blocked. -6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state - **stays 'disconnected'**: `migrateTo` only binds the new session's state after - `waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the - dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`) - fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial - delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`). -7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun. - -Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live -client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`) -— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host. - -Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the -network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The -asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a -connected direct client runs an activity probe that detects a half-open socket in ≤8s -(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground` -(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is -suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls -`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink -(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`). - -Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber, -`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a -missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey -during the async client acquire (Keychain read) that home never shows. - -### S3 — silent 5–10s relay-forced pairing - -Pairing phase: - -1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing` - (`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`). -2. The coordinator threads it **only to the direct candidate** - (`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate - (`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all - (`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution, - journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`. -3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its - 12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE - handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write - (`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for - where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it. -4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director - resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped - at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of - silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the - director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay - request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`). -5. Pairing logs also never reach `connectionLogStore` (single producer: - `src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a - pairing that just failed. - -Post-pairing phase: - -6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/` - (`app/pair-confirm.tsx:118-123`). -7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the - Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first** - (`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s` - (`src/transport/rpc-client.ts:126`) on a black-holed LAN. -8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live - progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor - start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`). -9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism - as S2) → green. Worst case with a director resolution failure and grace-credential retry: - ~29–58s under the old session's labels. -10. The "Orca Relay" path label only renders once `state === 'connected'` - (`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only - after the wait ends, and `classifyConnection` has no relay-aware branch - (`src/transport/connection-health.ts:45-100`). - -## 3. Anti-pattern sweep - -### (a) Uncoordinated deep pushes into `/h` from outside the host stack - -Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and -Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the -root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks -proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`). - -Bare pushes remaining (host stack plausibly cold at each): - -| Call site | Target | Cold scenario | -|---|---|---| -| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h//session/` or `/h/` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener | -| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 | -| `app/index.tsx:835` (Account-usage card) | `/h//accounts` | Home is the root route | -| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes | -| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first | - -Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination. -No ``, `navigationRef`, or `router.navigate` anywhere in `mobile/`. - -Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit -host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight -(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts -(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount -can be destroyed by the gate cycle. - -### (b) Surfaces that render grey 'disconnected' during expected transients - -Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing -entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound -fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's -`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also -refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved -locally what every other surface gets wrong. - -The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass) -**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold -start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical -client is not the cause — it already reports `'connecting'` synchronously by the time `connect()` -returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the -async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before -amber**. - -Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via -`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled -(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for -`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner -(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty -state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all, -`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat -composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history / -diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log -screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick -Action gates all read `=== 'connected'`; voice settings goes fully inert -(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts -screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`). - -Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on -focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` — -correct per billing, but state stays grey through the entire foreground re-dial rather than -flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the -pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths -(`:107,114,135`). - -Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**: -`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect; -`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets; -`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent -lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` → -`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch -(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is -wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read -only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s -entry means an empty remount. - -Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to -"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863` -and `tasks.tsx:10970`). - -### (c) Invisible relay establishment phases - -- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay - session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the - supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`). -- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states - (`src/transport/stable-logical-rpc-client.ts:182-223,267-299`). -- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`, - `mobile-relay-direct-upgrade-controller.ts:19`). -- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`. -- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`); - `classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch. -- Regression suite for connect-label stalls exists for the direct path only - (`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent. - -## 4. Fix plan - -Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire -protocol, so every old/new phone × old/new desktop pairing keeps working unless noted. - -### F0 (S1, quick win) — land PR #12001 ✅ MERGED - -Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check -against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and -auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR -branch. The PR routes Resume through the shared mount-then-replace mechanism -(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the -bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the -extracted module. -Backward compat: navigation-only, none. -Residuals tracked as F4/F7/F8/F9. - -### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch) - -Approach: split the nudge reasons that today all funnel into `setForeground(true)`: - -- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either - no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short - budget) and only enter recovery on failure — mirroring the direct path's activity probe. -- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement** - instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path - (proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the - replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying - green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is - still `'connected'`, so the fallback suspend is required for convergence). - -Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`), -`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend), -`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged -`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter. -Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing -`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already -accepts this). Half-open regression risk is covered by the fallback suspend. -Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot -stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the -background-suspend test unchanged. - -### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch) - -Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or -pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every -host screen with home's `?? 'connecting'`. Two companion changes in the same class: -- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving - the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button - currently drives the UI grey before amber. -- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the - cold-start gap (`client-context.tsx:87-153`) is amber too. - -**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to -`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/ -`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without -extending that predicate would silently disable the stale-list fallback and blank the list — -extend it to every not-connected state (or key it on "no live fetch has succeeded this mount"). -Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`, -`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate), -`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead -of `null`). -Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates — -acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that -key on `'disconnected'` specifically. -Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence; -host screen test for the stale-list fallback under `'connecting'`. - -### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch) - -Approach: add an optional `onLog` to `connectMobileRelayForPairing`, -`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread -`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay: -resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated", -"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore` -under the resolved host id so the Connection Log screen has a record post-pairing. -Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`, -`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`. -Risk: none (additive, phone-local). Old desktops: unaffected — logging only. -Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`; -extend `pairing-relay-candidate.test.ts` for per-attempt lines. - -### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes - -Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`) -and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts` -once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted -follow-up). `orca://` deep links can follow later via a route-level guard. -Risk: notification cold-start ordering (push before root nav ready) — the mechanism already -tolerates that by waiting for state commits. -Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target. - -### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo` - -Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the -dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on -success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a -still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI: -show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let -`MobileHostCard`/`classifyConnection` render it while not yet connected. -Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`, -`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`. -Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the -forwarding strictly gated on suspended/disconnected. -Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases: -forwarded states during suspended dial, no forwarding during live-session replacement, failure -restores `'disconnected'`. - -### F6 (S3, deeper) — happy-eyeballs relay start post-pairing - -Approach: when a relay credential bundle exists and the direct dial has not authenticated within a -short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure; -first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to -the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay -sockets on healthy LANs. -Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a -phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so -the path is naturally guarded). -Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex -(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too -broadly. -Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a -relay socket; relay loser closed after direct wins. - -### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce - -Approach: (1) on Resume tap with the host connected, validate the target against the freshest -`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead. -(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s -(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`) -to the host index with a notice. (3) Use the validating reader in -`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse` -(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites. -Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss. -Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel -exemptions. - -### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift - -Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its -late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately -from the snapshot in a disabled state until the host connects. -Files: `app/index.tsx` footer. -Risk: none. -Tests: render test asserting footer order/height stability across `resumeWorktree` arrival. - -### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack - -Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending -spinner instead of replacing children, preserving in-flight nested navigation; keep the hard -replace only for the `blocked` verdict. -Files: `src/components/HostProtocolGate.tsx`. -Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an -overlay must still block interaction until resolved; verify child mount effects don't fire gated -RPCs pre-verdict before choosing overlay vs. current behavior. -Tests: gate test asserting no unmount across `statusPending` for an already-mounted host. - -### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips - -Approach: audit the §3(b) destructive-clear sites and make each preserve data across a -not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt -impact: git history blanking rows on the reconnect branch -(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo -cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on -error); the diff-review "ready-state preserved" branch that is dead code because it sits after the -early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe -(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration -(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount -(`app/h/[hostId]/index.tsx:129,330`). -Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts -rather than inventing new indicators. The in-repo reference pattern is -`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as -"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable` -preserves proven counts across a failed refresh, flagging only `staleCounts`. -Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger* -(suspend blips), so this is hardening, not the primary fix. - -## 5. Backward compatibility (old/new phone × old/new desktop) - -- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing - steps change. Old phones against any desktop are untouched (they don't have the code). -- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths - never activate; pairing keeps the existing `method_not_found` downgrade - (`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay - candidate is created). -- New phone + old desktop with relay: all paths use existing RPCs (`status.get`, - `pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses - the same resume-credential flow lease rotation already exercises against production desktops. -- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today. - -## 6. Constants appendix (verified) - -| Constant | Value | Where | -|---|---|---| -| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` | -| Direct handshake timeout | 5s | `rpc-client.ts:127` | -| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` | -| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` | -| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` | -| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` | -| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` | -| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` | -| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` | -| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` | -| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` | -| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` | -| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |