Commit Graph
25 Commits
Author SHA1 Message Date
Jinwoo Hong cb848647e5 fix(browser-preview): require explicit preview capabilities (STA-5758) (#16921)
* fix(browser-preview): require explicit preview capabilities (STA-5758)

Scope document reads to approved directories, confirm external links before opening them, revoke grants with tab lifecycle, and keep document-preview session state rollback-safe across mixed client/runtime versions.

* Harden document preview lifecycle and permissions

* Document preview DNS prefetch residual

* Make preview E2E guest focus explicit

* fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758)

A grant whose document directory is its own request base — a doc at the
workspace root, or outside any workspace — now reads nothing but the entry
file until the reader approves a directory, at both the lexical and the
canonical containment pass. The DNS-prefetch residual can only beacon what
the page can read, and a root-level document could previously read the
whole worktree silently.

The identity chip's host badge overflowed the chip's layout box under
squeeze (Linux CI): every row member can now shrink and truncate, verified
by a width sweep in isolated Chromium down to ~120px chips.

The Allow banner says what it grants: 'Allow folder', reading files in the
named directory, for the life of the preview.

The reliability-gate manifest command, testFiles entry, assertion refs and
dated evidence naming the deleted doc-preview-external-link-bridge.test.ts
are re-issued at doc-preview-external-link-confirmation.test.ts with a
fresh 189/189 run; the focus-gate assertion text follows the shipped gate.

* fix(browser-preview): hide the chip identity row below 24rem instead of clipping it, ellipsize the host badge, catalog the new i18n keys (STA-5758)

CI's preview pane leaves the chip ~40px: no truncation shows anything
there, so the Workspace-file label and host badge now hide whole below a
24rem container threshold sized so that visible implies contained. The
badge text gains an inner text box — text directly inside the flex pill
clipped both ends with no ellipsis. The e2e geometry oracle asserts
containment when the row shows and the threshold when it does not.

verify:localization-catalog: the hardening's new preview keys (and the
renamed allowDirectory) join en.json via sync:localization-catalog.

* feat(browser-preview): batch blocked folders into one access decision (STA-5758)

Sequential per-folder banners trained the allow reflex without adding
judgment — a reader cannot weigh assets/ against data/. The banner now
accumulates every folder a load surfaces, names them (three, then a
count, full list in the title), and grants exactly that set with one
Allow-N-folders click and one reload. Dismiss fences the whole named
set. The map lives behind a ref with a version tick so a dismissal
fences an offer landing in the same event batch.
2026-08-28 00:42:07 -04:00
Jinjing 52ade074a9 Display host on automation details and dialog (#16823)
* Show automation host in details and support moving between hosts

- Rename AutomationCreateDestinationField to AutomationDestinationField to
  reflect dual use in create and edit modes
- Add host display to automation detail view, showing storage authority
- Allow editing automations to move them to different hosts within same
  authority; project list filters to available projects on chosen host
- Update copy from create-only terminology to mode-agnostic wording

* Display automation host and support cross-authority moves

Users can now move automations to different storage authorities. The
destination picker shows all available hosts, and selecting a new one
displays a warning about the move. The save creates the automation on
the destination and deletes it from the source; if deletion fails,
both copies remain and the user is notified.

* Remove cross-authority move support for automations

An automation's authority (the Orca instance that stores and schedules it)
cannot change; edits now only offer hosts within the same authority and
move logic is removed entirely. This simplifies the destination picker and
removes move-specific UI messaging.

* Fix undefined selectedRowKey in automation host recovery

Replace references to the undefined selectedRowKey variable with
selectedRow?.key to properly access the row's key when recovering
automation runs across hosts.

* Support moving automations across execution authorities

Allows users to move automations between different authorities (desktop ↔ runtime environments) during editing. A save to a different authority creates the automation on the destination and deletes the original with its run history. Includes clear messaging about the move operation, proper handling of workspace id resets, and graceful error handling when deletion fails. Supports destination-aware project and worktree fetching.

* Reuse creationKey across move retries when schedule changes

When retrying a failed automation move, dtstart is minted fresh each
attempt, changing the payload. Previously, operationKey included the
full payload, so retries would mint new creationKeys and risk duplicate
automations on the destination if the initial create failed in transport.
Now key only by the move (source + destination) to ensure stable
creationKey across retries.

Also fix workspace auto-selection to use authority-scoped worktrees
instead of the merged cache, preventing unwanted restoration of
source-host workspaces after switching authorities.

* Rename `note` to `moveWarning` for automation host moves

Clarifies that the field specifically warns when an automation would move to another host, replacing the plain storage line.
2026-08-27 20:10:17 -07:00
Neil a7b1da9e14 fix(ssh): a reattach may bind a pane but must never create one (#16751)
`reattachKnownPtys` treats every non-terminated lease as live and calls
`persistPtyBinding`, which had no way to say "bind only". Two of its branches
then rebuild UI the user is not asking for:

- `pty-binding-persistence.ts:133-143` -- `if (args.incarnationId)`
  unconditionally deletes the pane's close tombstone.
- `:145-160` -- on a tab-not-found it mints one via
  `createMinimalPersistedTerminalTab`. The in-code comment names its only
  intended caller: "pty:spawn can beat the debounced writer." Spawn. Reattach
  took the same branch.

This is the mechanism canceled ticket STA-4268 described: "Leases have no pane
incarnation and upsert only by target/PTY. Every nonterminal lease is reattached;
frozen coordinates are passed to persistPtyBinding, which creates and flushes
missing tabs and layout leaves." It was fixed in #13326, reverted by #14361,
re-applied by #14384, and reverted again by #14395 (opened and merged nine
seconds apart, empty commit body). `grep -rn "mayCreate" src/` returns nothing on
main -- the mechanism is genuinely out of the tree.

Four parts:

1. `mayCreate` (default true). When false, one pre-mutation check mirrors all
   four creating branches and returns `false` without mutating, so a refusal
   leaves nothing half-written.

2. The authority gate -- the part both prior attempts lacked, and probably why
   both were reverted. `mayCreate: false` alone refuses in two situations: "the
   user closed it" AND "the renderer has not published its layout yet". The
   second is routine on disconnect->reconnect and is almost certainly the #14361
   tab-loss mechanism. The fence was not wrong; it was UNCONDITIONED. It is now
   passed only when `hasHostAuthoritativeTerminalMembership()` says the persisted
   membership speaks for this worktree, reusing the function already guarding the
   same question at `orca-runtime.ts:8608`. Losing a tab is worse than keeping a
   duplicate, so an unauthoritative session still gets the creating write.

   Authority is read from `local` because that is the partition the write lands
   in -- it is local's absence being interpreted. But a pane the `ssh:<target>`
   partition still holds is not gone, so it keeps its creating write; refusing
   there would strand a live pane behind a binding reattach can no longer reach.
   (SSH spawns bind into `ssh:<target>` while this reattach binds into `local` --
   GH #12721/#12723, STA-3980. This does not fix that split; it refuses to judge
   from one side of it.)

3. `findTerminalTabIdForLeaf` -- bind resolves the tab from the live layout
   instead of the lease's frozen `tabId`. Only the leaf half of a pane key is
   remint-stable: `detachTerminalPaneToTab` moves a live pane into a new tab, so
   a stored tabId names the tab the pane left. Identity vs location.

4. Pane-keyed supersession -- retires siblings on `(targetId, worktreeId,
   leafId)` to `expired`, guarded by the durable binding, which reads `local`
   then `ssh:<target>` so it is correct whichever partition the binding landed
   in. `upsertSshRemotePtyLease` matched `(targetId, ptyId)` alone
   (`ssh-pty-lease-operations.ts:34-36`), so a new relay pty id on reattach minted
   a SECOND lease instead of updating the first, leaving the predecessor
   non-terminated with nothing to retire it.

On refusal the lease goes `expired`, never `terminated` -- `expired` records that
this shell has no surface to reach it through; `terminated` would assert an exit
nothing here observed, which `docs/reference/ssh-execution-boundary.md` forbids.
The remote process is left running.

Deliberately NOT done:
- A collision guard for `upsertSshRemotePtyLease`. Built, tested, and REMOVED --
  its own test passed with the guard disabled, i.e. vacuous. Telling "same lease"
  from "recycled id on a different shell" needs a relay-start identity, which
  would be a twelfth per-tab identity concept; the codebase already carries
  eleven (784 refs) that SSH-v3 Phase 3 deletes. Left as an in-code NOTE. This
  handles lease DIVERGENCE, not COLLISION.
- A port of #13324. Its own authors deleted its load fold and reverted its
  local-only reader in #13326 ("a headless-owned pane still gets a vote before
  its lease is retired"); porting it ships a state-destroying migration they
  removed.
- `bindPaneShell` from #13325. Its purpose is making `isSupersededPtyId` live,
  and that fence does not exist in main. It would have been a refactor plus a
  silently-ignored `mayCreate` -- TS drops excess props through spreads
  (verified), which is why `mayCreate` is passed as a conditional spread here.

Honesty about scope: this does NOT close the daily-tab-growth report. The
deterministic e2e repro (`ssh-lost-kill-tab-resurrection.spec.ts`, later in this
stack) is byte-identical before and after, and instrumentation shows why -- in
that scenario `restoreReattachedPtyRuntime` is never called at all
(`CREATING TAB` 9 hits, `reattach gate` 0, `BYPASS` 0). The fence is on a path
that bug does not take. It is a real, separately-provable defect; it is not the
headline fix, and must not be claimed as one.

Evidence, A/B on this tree. Disabling the authority gate (`mayCreate = true`) and
the supersession call by hand: **8 of 12 fail**, including "does not resurrect a
tab whose closing pty.kill failed with a transport error" and "holds the live
lease count flat across ten reconnects of one pane"
(`[ 'pty-0', 'pty-1', 'pty-2', …(7) ]` vs `[ 'pty-9' ]`). The 4 that pass both
ways are the over-refusal tripwires, which is the point of having them. Restored:
12 passed; 2,439 passed across `src/main/ssh`, `src/main/persistence` and
`src/main/runtime/workspace-session`.
2026-08-27 19:45:49 -07:00
Neil 7ee8b5e1a6 Refactor lower max-lines modules (#16760) 2026-08-27 16:10:51 -07:00
Neil b241a68ae4 Fix worktree identity collisions across hosts (#16691)
* fix(workspaces): add collision-safe worktree identity

* fix(workspaces): read worktree metadata per host and repair ambiguous identities

The canonical identity store landed write-only: getWorktreeMetaForHost had no
production callers while setWorktreeMetaForHost kept the legacy projection only
for the first known owner, so a second host's edits persisted and were never
read back. Wire the listing paths through host-qualified reads.

An ambiguous alias was also unrecoverable — reads returned undefined and writes
threw forever, and the throw escaped the detected-worktree loop, emptying the
whole repo's sidebar. Fail open onto the most recently active instance instead.

- collapse ambiguous aliases deterministically and persist the repair
- reclaim identity rows in the metadata GC so they cannot outlive their locator
  or resurrect onto a worktree recreated at the same path
- drop every host's rows when a locator is removed outright, not just the owner's
- honour an explicit instanceId so the stale-lineage rotation guard still works
- scope a rename to the moving host; other hosts keep their own locator
- prefer the project host setup matching the repo's own execution host, so a
  repoId registered on two hosts no longer stamps the wrong one durably
- reject an unencoded `|` in a host id, the invariant the alias delimiter needs
- drop the never-populated hostGeneration from the canonical key

* fix(workspaces): close remaining identity review gaps

* fix(workspaces): close remaining review gaps

* fix(workspaces): address review and CI regressions

* test(workspaces): update host-qualified metadata expectations

* fix(workspaces): preserve ambiguous identity records

* fix(workspaces): snapshot metadata during listing

* test(workspaces): mirror listing metadata snapshot in windows fixture

* fix(workspaces): preserve identity routing for metadata writes

* fix(workspaces): scope stale metadata cleanup by host

* fix(workspaces): rekey identities on SSH readoption

* fix(workspaces): fail closed for ambiguous board ids

* perf(workspaces): snapshot metadata across catalog listing

* fix(workspaces): retain neighboring manual order updates

* test(workspaces): cover ambiguous board id index

* fix(persistence): harden host-qualified worktree metadata

* refactor(shared): split project host setup lookup

* refactor(workspaces): simplify host-qualified metadata
2026-08-27 15:08:40 -07:00
Jinjing 9635e6822f Normalize project catalog rows defensively (#16826)
* wip: normalize untrusted project catalog rows at load boundaries

* fix(catalog): make ProjectHostSetup field types true at the ingest boundary

Crash 3bcc5be3: a setup row whose repoId arrived null reached Settings'
projectByRepoId memo and threw on .trim(). The type said `string`; persisted
JSON and remote hosts on other versions can disagree.

Normalize project/setup rows where untrusted data enters typed code — the
persisted-state load (marking dirty, which is the migration), profile
transfer reads, the repo-derived projection, and the renderer's IPC/RPC
ingest and adoption steps — instead of re-guarding each consumer. Also
covers `setup.path`, whose identical crash is on the sidebar render path.

Coercion only: never drops a row, never adds or removes an optional key, and
returns input references when a row already conforms, so selector and useMemo
identity is unchanged.

* refactor(catalog): drop the `as` casts the normalizer introduced

A change whose thesis is "stop the declared types from lying" should not use
`as` to paper over types.

The four source casts all came from the row normalizers returning `readonly`
arrays into mutably-owned fields. Take and return mutable arrays instead, and
copy at the one caller that holds a readonly projection — where identity is
not load-bearing, unlike the persistence path, whose dirty check compares it.

Tests built deliberately malformed rows by casting a literal. Build a valid
row and `Reflect.set` the bad value onto it, which says outright that the
fixture violates its type; parse the non-array case from JSON, which is how
it actually arrives. Fixtures that were merely incomplete needed no cast at
all — `Repo` requires only the five fields they already had.

Also narrow normalizeLoadedProjectCatalog to the two fields it reads.

* refactor(profiles): validate untrusted profile JSON instead of asserting it

Removes the last introduced cast and the unsound pre-existing ones in the
file this change already touches.

The test cast is gone because narrowing normalizeLoadedProjectCatalog to the
two fields it reads made `{}` assignable on its own.

arrayOrEmpty and recordOrEmpty checked the shape and then asserted the
element type, which is the same "declared type is a promise the data does not
keep" problem this change exists to fix. Array.isArray already narrows on its
own, and a generic isRecord narrows the value part, so both assertions delete
outright. JSON.parse returns any, so annotating the binding beats asserting
its result.

The two remaining `as const` in project-host-setup-actions are untouched and
deliberate: a literal assertion narrows a type rather than overriding it.

* Make project catalog normalizers handle null rows defensively

Instead of crashing when corrupt or null catalog rows are encountered,
the normalizers now gracefully repair them with default values. Refactored
helper functions for clarity and added type predicates to improve type
narrowing.
2026-08-27 12:35:03 -07:00
Brennan Benson 8a07bbd8cf fix(orchestration): enforce nested worker depth instead of an accidental fence (#16668)
* fix(orchestration): enforce nested worker depth instead of an accidental fence

Orca documented that "dispatched workers cannot spawn their own sub-workers
(worker-start is coordinator-fenced)". No such check existed. What existed was a
single Run-binding check in the workerStart RPC: a worker's terminal is not bound
to a Run, so worker-start happened to fail. The rule was emergent, asserted by no
test, and written in no doc — and it leaked. A worker could run-create its own
Run, task-create, and worker-start: now bound, the check passed.

Replace it with a real, configurable depth cap.

Depth is derived from the caller's own active Dispatch rather than from Run
binding, which is what dissolves the run-create bypass: creating a Run does not
stop you being a worker. Enforcement lives in a single dispatch-row writer that
owns all three INSERTs that mint a live worker — the generic claim, the supervised
worker-start path (including every retry), and the remote attachment. Two of those
were missed by earlier drafts of this change, so `creator` and `maxDepth` are
required parameters: a new spawn path cannot compile without deciding, and a
boundary test refuses the SQL anywhere else.

Schema v30 adds depth to dispatch_contexts and remote_dispatch_attachments,
NOT NULL DEFAULT 1 and backfilled to 1 so an unstamped or pre-upgrade row fails
closed rather than reading as a root coordinator. The attachment pane indexes
widen to the five states in which a remote worker may still be running:
loss of contact is not evidence of process death, so an unverifiable worker still
counts as a nesting parent.

Also adds the caller-evidence assertion that workerStart was the only Run-scoped
verb to skip, so a declared --from cannot name another terminal's pane and inherit
its depth.

Default is 1, so behaviour is unchanged unless the new setting is raised. Two
limitations are deliberate and documented rather than papered over: this is a
guardrail and not a security boundary, since a caller whose launch evidence is
unverifiable (any ordinary restored terminal) can declare another handle; and it
is enforced at supervised dispatch creation, so a settled worker whose process is
still alive counts as a root again.

* fix(orchestration): share caller resolution and pin worker gaps

* refactor(orchestration): make the caller resolver's pane contract explicit

Overloads so requireStablePane callers get a non-null string instead of casting,
and rename the attestation opt-out to say what it means: the caller asserts it
itself. A flag called assertEvidence:false reads as "attestation optional",
which is the hole this helper exists to close.

* fix(orchestration): propagate dispatch depth to federated workers

* chore(cli): refresh bundled orchestration guide
2026-08-26 13:22:09 -07:00
Jinjing cda2280d63 Show all automations (#16532)
* Add all-host automations with scoped ownership and multi-authority suppo

Enable automations to run on multiple hosts (SSH targets and local) with
owner-fenced mutations, scoped list queries per host, and conflict
resolution. Introduces desktop and runtime authorities as distinct
automation storage owners, with per-host caching, invalidation, and
retry scheduling on the renderer. Captures registration generations for
SSH hosts to survive re-adoption. Adds CLI support for destination
selection and conflict recovery.

* Filter automation create projects by destination host

Only offer projects available on the selected destination, preventing
the mismatches that would fail at submit time. Auto-adjust the project
selection if it becomes unavailable when the destination changes.

* Add runtime storage authority support for automations

- Support both runtime and desktop as automation storage authorities
- Make owner preconditions optional for legacy-client compatibility
- Cache automation list projections to improve performance
- Add per-row repo/worktree resolution for cross-authority collisions
- Extend automation.list RPC to always include owner metadata

* Replace child_process.execFile with runProcess for external automations

- Migrate external-manager to use cross-platform runProcess wrapper per child-process safety policy
- Abstract electron app/ipcMain APIs in orca-runtime via environment accessors
- Install fake app environment in automation tests for consistent setup
- Reorganize imports to use specific module paths (ssh-target-registry, agent-detection, browser-error)
- Remove external-manager from child-process import allowlists (no longer violates direct import)

* Unify desktop automation CRUD onto the local runtime RPC surface

The desktop authority now speaks the same automation.* RPC contract as
remote runtimes, via callRuntimeRpc({kind:'local'}) -> runtime:call ->
the shared RpcDispatcher. The automations:list/listRuns/create/update/
delete/runNow IPC arms, their preload members, and every renderer
desktop-vs-runtime transport fork are retired; the runtime methods are
the single implementation of scoped lists, owner fencing, and change
publication for both transports (mobile clients already exercised them).

The desktop probe scheduler's priority lease survives the move as an
AutomationService hook the IPC registration installs and the runtime
methods take, so Orca's own automation traffic still parks queued
external-manager probes.

External-manager scope arms and dispatch-loop plumbing stay on IPC by
design; automation change events keep their existing channels (renderer
ingestion already converges them by authority).

* Remove automation ghost SSH tombstone scanning

This functionality for synthesizing tombstones for automation-referenced SSH
targets is no longer needed as part of the automation system refactoring.

* Refuse orphan automations at dispatch time, not migration time

Remove migration-time disabling of orphan automations and the `enabledDecidedBy` field. Dispatch now refuses orphans at runtime instead, simplifying state management and UI. Orphans are left unstamped and enabled; dispatch refuses to run them via `resolveAutomationRunTarget`.

* Show all automations in flat table with unified filter menu

- Replace host picker component with comprehensive Filters menu supporting status, last run, agent, and host filters
- Flatten automation list layout to single table instead of host-grouped sections
- Add Host column to display execution host for each automation
- Display active filters as removable pills below toolbar
- Delete unused AutomationHostPicker* components

* Add automation owner fencing and destination validation

- New AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY for owner preconditions; legacy clients get owner metadata snapshotted at RPC boundary for compatibility
- Editor captures and revalidates automation destination before save, preventing silent retargeting if SSH infrastructure changes mid-edit
- SSH target types now isolate renderer-authored fields; generation is server-owned and stripped by IPC handlers

* Route automation recovery actions to the origin host

When an automation action fails due to owner fencing, recovery verbs
("Update server", "Reconnect") must run on the host where the refusal
originated: the row's captured owner for row operations, or the
destination the create dialog captured, not the list's filtered host.

* Remove external manager scope limitation notices

Consolidate create destination eligibility checks with a unified predicate
and fix the bug where desktop repo IDs could be sent to runtime hosts where
they cannot resolve.

* Persist only store-derived automation contexts, not client-perspective o

Store contexts must never be based on client-provided runContext or sourceContext
values—clients speak a different perspective (e.g., 'runtime:<id>' for host IDs
they assign), and persisting those makes the store projection orphan automations
it actually owns. Derived contexts now take precedence in create and update paths,
with explicit null still honored to clear a value. Tests verify this by simulating
drift after storage and confirming that moves re-derive while toggles preserve.
2026-08-26 09:50:12 -07:00
Jinwoo HongandJinwoo-H a9781a4118 STA-4150: client-hosted remote browser (consolidated) (#15448)
Co-authored-by: Jinwoo-H <jinwoo@stably.ai>
2026-08-25 15:36:51 -07:00
Neil 9bcaf09869 refactor loading store into cohesive domains (#16192) 2026-08-24 20:45:53 -07:00
Jinjing aa32871a61 Improve cmd j ranking with recency (#16281)
* Track container-only tokens and tab focus for cmd+j ranking

Previously ranked by whether any container-only matches existed (boolean);
now counts tokens matching only containers for finer-grained ranking. Tab
focus recency is now tracked explicitly so recent refocuses rank above
stale worktree activity. Preserves worktree grouping by input order while
applying focused-group MRU within each block.

* fix(cmd-j): preserve duplicate recent tab occurrences

* fix(cmd-j): preserve host scope during worktree purge

* fix(cmd-j): scope repo purge for exact-id host twins

* fix(cmd-j): scope ssh visit recency to local to survive restarts

Boot hydration loads only local + runtime:* partitions, so routing
ssh-qualified recency to ssh partitions strands it across restarts.

- Keep ssh-qualified visit timestamps in local partition
- Route runtime-qualified keys to their partition
- Remove groupId from recent tab occurrence base (unstable on regroup)
- Collapse bare and host-qualified timestamps, preserving max
- Simplify repo pruning host-match logic
- Add robustness: optional chaining, helper function

* Scope focused tab recency by worktree to fix Cmd+J ranking

Tab ids can be duplicated across worktrees; scoping recency keys to per-worktree prevents one worktree's MRU position from overwriting another's in Cmd+J. Scope worktree order blocks to (hostId, worktreeId) to keep same-id worktrees on different hosts separate.

Also fix recency preservation during partial identity migrations and prune orphaned host keys on removal.
2026-08-24 11:23:16 -07:00
Neil 03fcfdfb92 feat(orcad): boot the Orca runtime on plain Node (#15968)
* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* feat(orcad): boot the Orca runtime on plain Node

Closes the last two Electron couplings and makes `orcad` a working artifact:
a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git
worktree and round-trips a PTY — with zero `require("electron")`.

Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its
test asserts exactly that: any reachable electron import is a regression.

- speech: inject the service factories, so importing ModelManager for its type
  no longer drags Electron's streaming net.request into the graph
- filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those
  maps arrives through an ipcMain handler carrying a renderer sender, so a host
  with no renderer has nothing to close, restore or forget — the inert default
  is what the desktop code does against empty maps, not a stub hiding work
- user-data-path / profile-storage-paths: resolve userData through
  AppEnvironment. These surfaced only once orcad pulled the store in

Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives
the re-imported graph a fresh module copy, so a binding installed before the
reset silently read back as uninstalled.

The acceptance smoke drives both hosts through one code path (`--target
orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the
same contract of each. Wired into PR CI.

* test(smoke): remove the seeded workspace container, not just the worktree

* test(smoke): surface the server's stderr when it dies before ready

* fix(smoke): build node-pty for Node before booting orcad in CI

* fix(smoke): drive the CLI built from this checkout, not one on PATH

* docs(ratchet): say the baseline must stay empty, not merely shrink

* build(orcad): externalize only the native modules actually in the graph
2026-08-22 21:47:46 -07:00
Jinwoo Hong da6b9d8065 fix(terminal): stop orphaning live agent terminals across host restarts and graph syncs (#15644) 2026-08-21 17:11:17 -07:00
Brennan BensonandQA 64de8dd637 fix(workspaces): delete on the confirmed host, and make both hosts' rows selectable (STA-4343) (#15013)
* fix(workspaces): host-qualified workspace deletion (STA-4343, STA-4448)

Squashed integration of PR #14606 + the codex review-loop output, replayed
onto current main. Granular history preserved on brennanb2025/sta-4343-review-full.

Fixes the regression from #13413: a workspace id is repoId::path with no host
component, so the same repo at the same path on two hosts published one id for
two workspaces, and deletion routed by that id landed on whichever host routing
preferred - usually the ACTIVE one, not the row the user confirmed.

- removeWorktree takes a REQUIRED host-qualified WorktreeRemovalTarget; omitting
  the host is a type error. All destructive callers migrated.
- Projections dedup on (host, id), so two hosts render as two selectable rows
  while the createWorktree/fetchWorktrees race duplicate still collapses.
- Ephemeral VM cleanup is host-scoped. It matched on bare workspaceId, so the
  host-scoped delete path destroyed the SURVIVING host's VM and its unpushed
  filesystem - a leak fix that had become data destruction.
- Selection, keyboard routing, lineage grouping and Space rows carry host
  identity end to end; fixing the executor dedupe alone would have turned
  one-row intent into deleting both hosts.

Files split to stay under max-lines rather than raising any cap.

* refactor: split files that crossed max-lines

The review-loop commits used --no-verify, so the pre-commit hook never
enforced the caps. Extracted cohesive units rather than raising any limit:
renderer teardown, delete-with-toast, pinned-group rows, host-scope helpers,
workspace-kind predicates, filter actions, kanban drag selection, the
renderer removal result type, and the native-chat persistence tests.

* refactor(workspaces): extract cleanup deletion-phase selector

Clears the last max-lines violation and the import-type side effect the
changed-code gate flagged.

* refactor(sidebar): track the delete-dialog extraction modules

* fix(workspaces): preserve host identity across remaining surfaces

* fix(sidebar): re-carry host through the rewritten palette result model

#15170 replaced PaletteSearchResult while this PR was open. Re-applied the
host qualification on top of the new model instead of taking either side:
results carry worktreeHostId again, and the board filter keys its matched
set on host identity rather than the bare id.

Known gap, documented in the board test rather than deleted: searchWorktrees
resolves evidence through a `documents` map keyed by BARE worktree id, so two
same-id host rows collapse before this code sees them. Closing that belongs
with the palette work.

* test(cmd-j): pin the palette collision gap instead of asserting the old model

The palette collision test asserted two host-qualified rows, which #15170's
rewrite made unreachable: item ids are bare again and worktreeMap is id-keyed.

Rewritten to assert what holds — activation always names a host — and to pin
the defect it exposes: two same-id rows render on ONE command value, so React
sees duplicate keys and a click on the first row activates the second row's
host. That reproduces on main, so it is pre-existing, not from this PR. Pinned
rather than deleted so fixing it must update this test.

---------

Co-authored-by: QA <qa@local>
2026-08-17 15:57:07 -07:00
Jinjing 7c79a0f9e3 fix(persistence): harden persistence edge cases (#15171)
* fix persistence edge cases

* Persist original folderPath value without trimming

The guard validates that the trimmed path is non-empty; persist the original input value that passed validation rather than a transformed version.

* Fix cross-host pane conflicts and persistence edge cases

Prevent ambiguous routing when tab IDs are shared across host partitions by skipping alias registration for colliding tabs. Ensure repaired null lineage maps are marked as changed so they're re-saved on reload. Use execution host instead of connectionId for git username enrichment to handle runtime repos correctly.
2026-08-17 15:25:25 -07:00
Brennan Benson a7f1653415 fix(worktrees): keep retirement tombstones across project and SSH target re-add (#14917)
* fix(worktrees): keep retirement tombstones across project and SSH target re-add

Generated workspace names are retired so a name is never reissued onto a cwd
that still holds another workspace's Claude/Codex history. Two re-add paths
lost that record.

STA-4449 (local): retirement was stored only under `repo.id`. Removing a
project deletes that row and re-adding the same path mints a new id, so the
new repo starts with an empty registry. The on-disk backfill normally
re-seeds local repos, but it cannot recover a name whose only surviving
evidence is a Codex rollout JSONL — those are deliberately not scanned — so a
name spent under Codex with its workspace directory gone came back.

STA-4491 (SSH): the second, path-derived copy embedded the SSH target row id.
Row ids are minted fresh on every re-add, so `ssh:ssh-old:...` became
`ssh:ssh-new:...` and `reassignSshTargetId` migrated other carrier state but
not the retirement namespaces.

Keying the store on the namespace instead of `repo.id` was rejected in
`nestWorkspaces`, `worktreeBasePath` and `repo.path`, so a settings toggle
would orphan every retirement at once. `repo.id` therefore stays primary and
the path-derived namespace stays a mirror — a settings toggle loses the
mirror but keeps the repo row, a re-add loses the repo row but keeps the
mirror, and reads union both.

- Mirror local repos into the namespace too, not just remote ones.
- Key the namespace's host half on the SSH endpoint (host+port+username), the
  thing that actually decides which filesystem a path lands on, instead of the
  target row id. Reads also accept the pre-identity key so an upgrade keeps
  tombstones it already wrote, and `reassignSshTargetId` re-keys the rest.
- Cap the namespace map, which by design outlives the repos that wrote it and
  so has nothing to prune it per repo.

Endpoint identity is extracted from ssh-target-readoption.ts, which already
compared these fields for exactly the same reason, so re-adoption and
retirement cannot drift apart.

* fix(worktrees): copy shared SSH endpoint retirements instead of moving them

An endpoint identity is not owned by the target row that rotates: nothing
dedupes SSH targets by host|port|username, so a second live target can still
resolve to the same host. Moving the bucket stripped that target's tombstones
and reissued a path whose agent history is still on disk.

Row-id identities stay a move — reassignment leaves nothing pointing at them.

* fix(worktrees): carry retirement mirror across in-place SSH endpoint edits

Config sync rewrites host/port/username on the existing target row and a
runtime-owned target takes a fresh address from every provision, both keeping
the row id. No re-adoption runs, so nothing carried the endpoint-keyed mirror
across and it stranded — strictly worse than the pre-change key, which was the
row id and was invariant under these edits.

Also bound the map after a migration: a retained source bucket grows it, so the
cap has to be applied there too, and compare registries by membership rather
than size so an uncompacted destination cannot trade a folded name for a new
one and read as unchanged.

* fix(worktrees): skip retirement migration for on-demand runtime targets

An on-demand VM is discarded between provisions, so its fresh address reaches an
empty filesystem and a reissued name collides with nothing. Migrating there
would spend names against history that no longer exists, and because each
provision mints another address it would add a namespace bucket per run,
evicting the real tombstones of local and ordinary SSH repos.

* fix(worktrees): stop the namespace cap evicting what a migration just wrote

Two defects with one root cause. Assigning to an existing key leaves it in its
original insertion slot, so a merged destination kept the oldest position and the
trim deleted the bucket it had just enriched. Retained source buckets are older
than the destinations a copy appends, so at the cap the trim removed exactly the
sources the copy existed to keep — silently turning it back into a move. The trim
now exempts the keys the migration wrote or deliberately kept.

Also stop on-demand runtime workspaces writing namespace mirrors at all: each
provision reaches a discarded filesystem under a fresh address, so the entry can
never be read back and only spends a capped slot that a local or SSH project
needs. The repo-id row still records the name for the live session.

* fix(worktrees): re-insert migrated namespaces so the cap cannot undo a migration

Exempting keys from the trim protected them for that one call and no other. A
merged destination keeps its original insertion slot, so it sat at the front of
the eviction queue and the next unrelated retirement write dropped it — losing
both the migrated name and the name the destination already held, on a host that
had just been re-added.

Re-insert what the migration writes instead, the same discipline the ordinary
writer already follows, so insertion order reflects use. That also removes the
exemption, which could otherwise leave the map stuck at twice the cap until one
later write evicted the whole excess at once.

Corrects the runtime-gate comment as well: the mirror is unreadable after the
next provision, not immediately, so a remove/re-add inside one provision is a
real if narrow loss.

* fix(worktrees): refresh a retained namespace source even when its merge adds nothing

Replacing the trim exemption with re-insertion narrowed the protection: the
exemption covered every retained source, the re-insertion only covered sources
whose merge actually wrote. A copy whose destination already held the same names
was then neither re-inserted nor exempt, so the migration's own trim evicted the
shared source bucket ahead of hundreds of untouched ones — losing the tombstones
of a live sibling target still on that endpoint, which is what copying exists to
prevent.

A move's destination gets the same treatment: deleting the source makes it the
only remaining copy, so it has been used. Both are order-only and deliberately do
not set the changed flag, keeping an import that moved nothing from scheduling a
save.
2026-08-17 12:23:50 -07:00
OrcaWinandm4air f660aaab0d Remap SSH leases per execution host, not globally (#15009)
* Remap SSH leases per execution host, not globally

SSH lease leaf IDs are now remapped within their execution host partition, preventing silently empty tabs when the same tab name exists on multiple machines after restart. Also preserve folder workspace paths exactly as provided without trimming whitespace.

* rm review file

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-08-16 22:22:09 -07:00
Neil 85565a9302 reland(workspace): set project location from the create-worktree host picker (#14965)
* feat(workspace): reland set project location from the create-worktree host picker

Relands #14868 (reverted by #14912) with a fix for the regression that caused the
revert: setting a project location could change the path before Orca used it.

The retarget-after-setup path read the raw store record to find a just-created
setup, because the memoized picker options had not refreshed yet:

    useAppStore.getState().projectHostSetups.find(
      (candidate) => candidate.id === setupId && candidate.setupState === 'ready'
    )

That hand-rolls a second selection path that skips every rule the option builder
applies — repo eligibility, ephemeral-VM and runtime-owned SSH host exclusion,
and the one-setup-per-host dedupe whose own comment notes that
resolveWorkspaceCreationTarget takes the first project+host match and ignores the
rest. So the composer could be retargeted at a setup other than the canonical one
for that host, pointing creation at a different location than the one chosen.

Resolves through buildProjectHostSetupOptions against fresh store state instead,
so the fallback and the steady-state picker agree by construction.

STA-4547

* fix(workspace): sanitize the clone prefill and drop an abandoned set-location

Review follow-ups on this PR.

The "Clone from URL" prefill seeded the field with the verbatim `git remote` URL,
which can embed a PAT (`https://x-access-token:ghp_...@github.com/...`). The
clone then runs on the *target* host, writing that token into its .git/config —
a credential the user never typed into this flow, now readable by anyone on a
shared host. Strip it with the same sanitizer `getProvisionedRootRecipeRepoUrl`
already applies to the ephemeral-VM recipe URL. Extracted to
resolveProjectCloneUrlPrefill so the rule is directly testable.

The dialog also stays dismissable while a submit is in flight, and an SSH clone
is unbounded. A clone the user backed out of minutes earlier still called
onReady, silently moving the run target and resetting start-from under a form
they had since pointed at another host. Drop the result if the dialog went away.

* fix(workspace): re-arm the abandoned guard on mount

StrictMode runs mount/cleanup/mount, so latching `abandoned` on the first
cleanup left it true for the rest of the session and permanently suppressed
onReady — the app wraps its root in StrictMode. Reset it on mount.
2026-08-16 16:50:58 -07:00
Jinwoo Hong fa9b20cb41 feat(skills): reland private bundle sharing safely (#14934) 2026-08-16 13:45:54 -07:00
Jinjing b60df2e3d6 Revert "feat(workspace): set project location from the create-worktree host p…" (#14912)
This reverts commit e4e54a17d0.
2026-08-16 10:41:39 -07:00
Jinjing 763b1febeb Revert "feat(skills): add private bundle sharing (#14401)" (#14913)
This reverts commit 757fae28d7.
2026-08-16 10:39:57 -07:00
Neil e4e54a17d0 feat(workspace): set project location from the create-worktree host picker (#14868)
* feat(workspace): set project location from the create-worktree host picker

Hosts that still say "Project location not set" now get an inline Set location action. It opens a nested dialog over Create worktree so the in-progress form stays put.

* fix(workspace): replace unset-location status copy with a button

Drop the redundant "Project location not set" caption and show a Set project location action with a hover tooltip instead.

* refactor(workspace): tighten the set-project-location dialog

- reuse CreateProjectParentBrowser instead of a second host-filesystem browse view
- drop ProjectLocationBrowseTarget; parseExecutionHostId already models it
- single setLocation path in RunTargetCombobox (row, button, Enter)
- memoize the default clone URL instead of scanning on every store update
- fix missing required props in the new composer-card test

* fix(workspace): close the correctness gaps in set-project-location

- Escape in the host browser now backs out to the form instead of dismissing
  the dialog and discarding the half-filled path/clone URL. Radix dismisses
  from a document-capture listener, so only preventDefault can stop it.
- Drop the stopPropagation guards: window-capture (the composer's Escape
  handler) already ran by then, so they never protected it — the nestedDialogOpen
  gate does. They did silently kill RemoteFileBrowser's own key handling.
- Hide Set project location for host-local repo:<id> projects (folder projects,
  git repos with no remote). Linking on another host matches by project identity,
  which those have none of, so the call could only ever toast an error.
- Drop a standalone placeholder setup once a repo projection covers the same
  project+host, restoring the (projectId, hostId) uniqueness invariant. Setting a
  location on a host with a pending setup was leaving a ghost that sorts first and
  reads back as 'not set up'.
- Existing-folder submit label matched a catalog string reading 'Importing...'

* test(composer): follow the renamed local in the host-retarget source assertion
2026-08-16 03:30:47 -07:00
Jinwoo HongandE2E Test 757fae28d7 feat(skills): add private bundle sharing (#14401)
Co-authored-by: E2E Test <e2e@test.local>
2026-08-16 02:36:18 -07:00
Jinjing c066a557ea fix(persistence): address review feedback on extracted modules (#14862)
Follow-ups to the module extraction (#14252):

- Guard the lineage companion maps in worktree meta GC, and clear them
  alongside a corrupt worktreeMeta so stale rows can't re-attach.
- Repair a renamed worktree's stale lineage.worktreeId and flag the save.
- Key the git-username cache by execution host + path so the same checkout
  path on local/SSH/runtime hosts can't cross-hydrate usernames.
- Drop undefined keys before the automation update spread; a Partial with
  an explicit undefined blanked stored values.
- Normalize pane identities in every workspaceSessionsByHostId partition,
  not just the legacy blob, and use the merged leaf maps for lease and
  acknowledgement remapping.
- Reject duplicate preferred leaf ids so two panes can't collapse onto one
  pty/buffer/scrollback key.
- Let an explicit rightSidebarExplorerView outrank the legacy search-tab
  fallback; the legacy migration now runs on the raw payload at load.
- Record why the mobile pairing migration leaves its sources in place.
- Trim persisted folderPath, normalize synthesized worktree visibility
  preferences, validate notification settings field-by-field, strip
  undefined optional keys before the SSH lease merge, reuse `now` for an
  automation's updatedAt, and rename the pane alias registrar.
2026-08-16 00:36:21 -07:00
Jinjing 8b04e060fa refactor(persistence): extract modules to half persistence.ts (#14252)
* refactor(persistence): extract modules to half persistence.ts

* refactor(persistence): tighten the extracted operations seam

Review follow-ups on the module extraction, all behavior-neutral.

The extracted operations read and mutate the Store's state object in place, but
every seam typed it as a bare PersistedState, so nothing at the boundary said a
caller must pass the live reference — a future caller handing over a clone would
have its writes silently dropped. Name that contract: StoreOwnedPersistedState
carries it to every operations interface and every mutating free function.
normalizePersistedPaneIdentityState and backfillFolderScopeConnectionIds stay on
PersistedState; they build a fresh state rather than mutating the Store's.

The six *PersistenceOperations wrappers were constructed per delegate call. They
are stateless today, so this was inert, but any future instance state would be
lost between calls. Memoize them, and mark state and gitUsernameCache readonly
so the compiler enforces the single-assignment invariant memoizing them relies
on.

Also: restore flushSshPtyConsumerRecovery, whose inlining left its rationale
duplicated at both call sites; document that migrateWorktreeIdentity's boolean
gates the caller's save, since the extracted function kept no docs of its own;
and merge a duplicate shared/types import that was failing lint under
--deny-warnings.

* delete plan doc

* refactor(persistence): add error recovery and improve field cleanup

- Rollback failed migrations to prevent corrupted state that blocks retry
- Gracefully skip malformed entries in normalization instead of aborting
- Strip retired fields to prevent orphaned state and sync issues

* refactor(persistence): drop the redundant persistence- filename prefix

The extracted modules already live in src/main/persistence/, so name
them after the domain they own. Point leftover shared/types imports
at the real type modules while touching those files.

* refactor(persistence): optimize lookups and fix unsanitized updates

- Use Maps instead of repeated array searches for O(1) lookups
- Apply sanitized updates instead of raw input in ui-state-update
- Compare fields directly rather than JSON strings to avoid false dirty states from persisted key ordering differences

* refactor(persistence): group modules into lifecycle folders

Move the 42 flat persistence modules into six folders named for what the
module does, and lift the Store class out of the barrel so persistence.ts
becomes an 8-line public surface.

Bodies are unchanged: every moved file diffs clean against HEAD once import
blocks are excluded. Only import specifiers were rewritten, by resolving each
one to an absolute path and mapping it through the move map.

Store keeps its existing max-lines suppression; its baseline entry is repathed
rather than re-added. Its 119-method public API sets a ~525-line floor, so it
cannot meet the 400-line cap without breaking the API for 153 importers.

* Sanitize worktree visibility sources and preferences on hydration

Ensure invalid or corrupted data from disk (untracked whitespace,
relative paths, bogus preference values) is cleaned during load
rather than corrupting the in-memory store.
2026-08-15 21:41:01 -07:00