diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 99205390e59..c2b597fafbf 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,6 +2,10 @@ name: E2E run-name: E2E ${{ inputs.ref || github.ref }} +# Why: checkout + artifact upload only; callers can only further restrict. +permissions: + contents: read + on: workflow_call: inputs: @@ -78,7 +82,7 @@ jobs: name: e2e ${{ matrix.shard_name }} needs: build runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3306575661d..f5497280d23 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,8 +32,11 @@ jobs: - name: Lint run: pnpm exec oxlint --format github - - name: Check switch exhaustiveness - run: pnpm run lint:switch-exhaustiveness + - name: Enforce focused code-quality plugins + run: pnpm run audit:code-quality:native + + - name: Enforce type-aware code-quality baseline + run: pnpm run audit:code-quality:type-aware - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -44,12 +47,6 @@ jobs: - name: Check Zustand selector fan-out budget run: pnpm run check:zustand-selector-fanout - - name: Check styled scrollbars - run: pnpm check:styled-scrollbars - - - name: Check quadratic buffer concatenation - run: pnpm run check:quadratic-buffer-concat - - name: Check reliability gate manifest run: pnpm run check:reliability-gates @@ -268,6 +265,55 @@ jobs: - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked + # Why: regression specs under tests/e2e/** used to merge green without ever + # running — e2e.yml only fired on schedule/release (#10518). Path-filter so + # ordinary PRs stay light; any E2E suite change still gets a full shard run. + e2e-paths: + name: detect e2e path changes + runs-on: ubuntu-latest + if: github.event.pull_request.draft != true + # Why: detector only needs to read the checkout; do not inherit repo defaults. + permissions: + contents: read + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Filter E2E-relevant paths + id: filter + run: | + set -euo pipefail + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + # Why: capture first so a failed git diff does not look like "no matches" + # (pipeline status in `if` is not aborted by set -e). Merge-base limits the + # list to files this PR introduced, not base-branch drift. + CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")" + # Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so + # it needs its own pattern — a bare `playwright.` prefix matches no tracked + # file and would silently skip E2E when the runner config changes. + if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "E2E path changes detected" + else + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "No E2E path changes" + fi + + e2e: + name: e2e + needs: e2e-paths + if: needs.e2e-paths.outputs.should_run == 'true' + # Why: reusable e2e.yml only checkouts, builds, and uploads artifacts. + permissions: + contents: read + uses: ./.github/workflows/e2e.yml + verify: if: always() needs: @@ -280,6 +326,14 @@ jobs: runs-on: ubuntu-latest steps: + # Why: e2e is deliberately absent from needs. The suite is currently red on + # main (every scheduled run), so gating merges on it would block any PR that + # touches tests/e2e/** — including the ones fixing the suite. Until it is + # green the job runs and reports for E2E-path PRs without blocking. To flip + # it on: add `e2e` to needs, add E2E to the env below, and require + # `"$E2E" = success || skipped` after the loop — skipped is the normal + # result for a path-filtered job and must keep passing, so it has to be + # checked outside the loop or it would excuse the jobs above. - name: Require successful checks env: STATIC_ANALYSIS: ${{ needs.static_analysis.result }} diff --git a/.oxlintrc.json b/.oxlintrc.json index 5eb541a89c1..9c64c75323f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,12 +5,29 @@ { "name": "mobile-pairing", "specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs" + }, + { + "name": "app-store-performance", + "specifier": "./config/oxlint-plugins/app-store-performance.mjs" + }, + { + "name": "quadratic-buffer-concat", + "specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs" + }, + { + "name": "renderer-scrollbar-style", + "specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs" } ], "categories": { "correctness": "error" }, "rules": { + "app-store-performance/require-selector": "error", + "app-store-performance/no-identity-selector": "error", + "app-store-performance/no-fresh-selector-result": "error", + "no-fallthrough": "error", + "quadratic-buffer-concat/no-loop-carried-concat": "error", "react/jsx-no-duplicate-props": "error", "react/jsx-no-undef": "error", "react/no-children-prop": "error", @@ -93,6 +110,19 @@ "unicorn/throw-new-error": "error" }, "overrides": [ + { + "files": ["src/renderer/src/**/*.{ts,tsx}"], + "rules": { + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error" + } + }, + { + "files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"], + "rules": { + "quadratic-buffer-concat/no-loop-carried-concat": "off", + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off" + } + }, { "files": ["**/*.ts"], "rules": { diff --git a/ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md b/ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md deleted file mode 100644 index e7d9443cd95..00000000000 --- a/ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md +++ /dev/null @@ -1,604 +0,0 @@ -# Orchestration Structured Worker Output - -Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete -Scope: orchestration `worker-read` only -Last updated: 2026-07-24 - -## Summary - -`orca orchestration worker-read` currently reads bounded terminal output. That is always available, -but full-screen agent TUIs can make it noisy or incomplete. - -Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with: - -- the agent type, such as Codex or Claude; -- the provider session or conversation ID; and -- when available, the provider-reported transcript path. - -The sidebar, session resume, sleeping-agent recovery, and native chat already use this information. -The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the -server where the worker runs. - -The proposed behavior is: - -```text -worker-read - exact supported transcript is available -> structured transcript page - otherwise -> bounded terminal page -``` - -This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer. -It makes one existing observation command return the best source Orca can prove. - -## User-facing goal - -A coordinator should be able to inspect a worker with one predictable command: - -```bash -orca orchestration worker-read --dispatch --json -``` - -The coordinator should not need to know: - -- which Orca server owns the worker; -- the worker's terminal or pane handle; -- the provider session ID; -- where a transcript lives on disk; or -- whether structured reading is supported by that provider/server version. - -The response must always say which source was used. It must never silently read a different agent -session. - -## Why the existing sidebar is relevant - -The agent logo identifies the detected agent type. By itself, that is not enough to choose a -transcript. - -The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks. -That is the useful foundation: - -```text -Dispatch - -> exact worker process and terminal - -> exact tab/leaf pane - -> hook-reported provider session - -> provider transcript locator -``` - -Some live status ownership is currently renderer-centric, while headless and mobile graph paths -also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup -that exposes the current exact pane association to `worker-read`. This is a small bridge over -existing status data, not a second agent-status system. - -## Design principles - -### Exactness over convenience - -- Never select the "latest session in this directory." -- Never select a transcript from a terminal title or logo alone. -- Never switch sources or sessions in the middle of a cursor chain. -- If Orca cannot prove the association, return a labeled terminal fallback. - -### Resource-local reads - -The server running the worker resolves and reads its transcript. A Run home on macOS must not try -to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path. - -Only bounded output data crosses the federation connection. Transcript paths do not. - -### One simple agent command - -Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to -automatic source selection. Source selection flags exist for debugging and explicit policy, not -because they are required in the normal loop. - -### Narrow provider support - -Initial support should cover only providers for which Orca already has: - -1. an exact pane-scoped session association; and -2. an existing bounded transcript reader with test coverage. - -Codex is the required first provider. Claude may ship in the same change only if it uses the same -proven reader path without adding a second architecture. Other agents receive terminal fallback. - -### Honest compatibility - -Connected servers can run different Orca versions. A server without structured-read support must -continue to return bounded terminal output rather than failing the whole Run. - -## Public command contract - -### Request - -```bash -orca orchestration worker-read \ - --dispatch \ - [--source auto|transcript|terminal] \ - [--cursor ] \ - [--limit ] \ - [--json] -``` - -`--source` behavior: - -| Value | Behavior | -| ------------ | -------------------------------------------------------------------------------------- | -| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. | -| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. | -| `terminal` | Use the current bounded terminal reader. | - -Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can -pin either source without exposing provider paths. - -### Response - -The implemented structured response has this shape: - -```json -{ - "dispatchId": "dispatch_123", - "source": "transcript", - "sourceIdentity": "opaque-source-fingerprint", - "provider": "codex", - "transcript": { - "messages": [], - "nextCursor": "opaque-next-cursor", - "limited": false, - "returnedMessageCount": 0 - }, - "cursor": "opaque-next-cursor", - "status": { - "worker": "running", - "terminal": "running" - }, - "fallbackReason": null, - "warnings": [] -} -``` - -Terminal fallback uses the same envelope and keeps the existing terminal data: - -```json -{ - "dispatchId": "dispatch_123", - "source": "terminal", - "sourceIdentity": "opaque-terminal-incarnation", - "terminal": { - "tail": ["..."], - "status": "running", - "nextCursor": "..." - }, - "cursor": "opaque-next-cursor", - "fallbackReason": "session_not_reported", - "warnings": [] -} -``` - -For backward compatibility: - -- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and - `terminal.nextCursor` fields; -- non-JSON output prints readable transcript entries or terminal lines without requiring an agent - to branch on JSON manually; -- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as - a labeled terminal response. - -### Fallback reasons - -Fallback reasons are bounded typed values, not arbitrary policy: - -- `provider_unsupported` -- `session_not_reported` -- `transcript_missing` -- `transcript_unreadable` -- `transcript_parse_failed` -- `remote_capability_unavailable` - -Warnings may provide safe diagnostic context, but must not contain a remote filesystem path. - -### Typed errors - -Errors are used when returning any source would be misleading: - -- `dispatch_not_found` -- `worker_identity_changed` -- `source_changed` -- `cursor_invalid` -- `cursor_dispatch_mismatch` -- `transcript_required` -- the existing connected-server unavailable/unknown result - -`source_changed` means the exact pane is now associated with a different provider session than the -one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently -jumps to the replacement session. - -## Source identity and cursor rules - -The cursor is opaque to clients and contains only non-sensitive routing data: - -- cursor version; -- Dispatch ID; -- source kind; -- an opaque digest of the exact source identity; -- provider-specific or terminal paging position. - -It must not contain a transcript path. - -On every continued read, the worker server: - -1. revalidates the Dispatch's exact process attachment; -2. resolves the current pane/session association; -3. compares its source digest with the cursor; -4. reads only when they still match; and -5. otherwise returns `source_changed` or `worker_identity_changed`. - -The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the -first page. - -The implementation uses a versioned stateless token. The token is not an authority credential: -every read revalidates the Dispatch, process, pane, source digest, and provider session before -returning data. This lets paging survive an Orca restart without adding cursor-secret persistence. - -## Runtime architecture - -### 1. Resolve the Dispatch at its Run home - -The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its -pinned worker server exactly as `worker-show` and the current `worker-read` do. - -No automatic placement or server selection is added. - -Current implementation anchors: - -- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated - `worker-read` routing. -- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached - worker. -- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering. - -### 2. Route the read to the worker server - -For a local worker, the Run home and worker server are the same runtime. - -For a federated worker, the Run home calls the existing federation read route on the server pinned -to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a -terminal handle, session ID, or transcript path chosen by the coordinator. - -### 3. Revalidate the exact worker - -The worker server uses the existing Dispatch attachment to verify: - -- the exact managed pane; -- the exact terminal/process incarnation; and -- that the Dispatch has not been replaced, stopped, or detached. - -This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads. - -### 4. Resolve the pane's provider session - -Add one runtime-owned resolver that returns a snapshot similar to: - -```ts -type ExactWorkerProviderSession = { - paneKey: string - agent: TuiAgent - providerSession: AgentProviderSessionMetadata - observedAt: number -} -``` - -The resolver may use the current runtime graph/headless retained status, but it must accept the -Dispatch's exact pane identity as input. It must not search all sessions by working directory or -agent type. - -The source association is considered usable only when: - -- it belongs to the Dispatch's exact current pane/process; -- the hook metadata is fresh enough to belong to that process incarnation; -- the provider session metadata passes existing normalization/canonicalization; and -- a supported adapter can resolve an exact transcript. - -If these checks fail under `auto`, the read falls back to terminal output. - -Current session/status anchors: - -- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata. -- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status. -- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots. -- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those - snapshots. - -### 5. Read through a narrow adapter - -Reuse the existing bounded native-chat transcript parser rather than adding another parser stack. -Extract or wrap its pure reader behind a small orchestration adapter: - -```ts -type WorkerTranscriptReader = { - provider: 'codex' | 'claude' | 'openclaude' | 'grok' - readPage(input: ExactTranscriptRead): Promise -} -``` - -This is deliberately not a registry for every possible agent capability. Add an adapter only when -an exact locator and a tested reader already exist. - -Current reader anchors: - -- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions. -- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC. -- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer. - -The transcript response should preserve the existing structured message/block representation and -the supported fields that the proven reader already understands. Unknown or skipped input should -produce parsing warnings rather than being silently presented as a complete transcript. - -### 6. Return bounded data - -Every path enforces: - -- at most 50 transcript messages per page (40 by default); -- a maximum serialized response size; -- existing clipping/redaction rules for large tool input and output; -- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens; -- deterministic pagination; and -- no transcript path leakage. - -Transcript observation is read-only. A failure or unknown network result must never trigger worker -restart, retry, stop, or Task mutation. - -## Federation and cross-platform behavior - -The core topology is: - -```text -Mac Run home - -> authenticated connected-server RPC - -> Windows worker server - -> exact Windows pane/session - -> Windows-local transcript reader - -> bounded structured page back to Mac -``` - -The reverse direction must work identically. - -Platform rules: - -- Use Node path operations only on the server that owns the path. -- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows. -- SSH and WSL execution remain behind their owning Orca server. -- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must - use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the - Run home. -- Mixed-version capability negotiation applies only at the Orca server protocol boundary. -- A server that does not advertise structured worker read receives the existing terminal-read RPC. - -Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes -it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing -`orchestration.federationRead` terminal method and wraps that result as a labeled -`remote_capability_unavailable` fallback. No generalized capability matrix is added. - -## Lifecycle behavior - -### Provider session appears after worker start - -Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return -terminal output. A later first-page `auto` read may select transcript output. - -Once a cursor is returned, that cursor remains pinned to its selected source. - -### Provider session changes - -Compaction, resume, or process replacement may produce a new provider session: - -- a fresh read without a cursor may select the new exact session; -- a cursor for the old session returns `source_changed`; -- Orca does not merge the old and new transcripts implicitly. - -### Orca restart - -After restart, the worker server re-establishes the exact pane/process association using the same -runtime graph and retained-hook mechanisms used by sidebar/session recovery. - -- If exact identity and session still match, paging continues. -- If process identity is uncertain, return `worker_identity_changed`. -- If only transcript identity is unavailable, `auto` may start a new terminal page but must not - reinterpret an old transcript cursor as a terminal cursor. - -### Disconnect - -A disconnected federated read is a read-only unknown result. Reissuing the same read is safe. -No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed. - -## Implementation plan - -### Work package 1 — Correct the contract and types - -- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session - association does not exist. -- Add the source preference, response envelope, fallback enums, and opaque cursor types. -- Keep the existing terminal response fields compatible. -- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors. - -Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes. - -### Work package 2 — Runtime exact-session resolver - -- Add a runtime method that resolves agent status for an exact Dispatch pane/process. -- Reuse the existing graph/headless retained status and provider-session normalization. -- Reject stale pane or process-incarnation associations. -- Test multiple panes and multiple sessions in the same worktree and directory. - -Exit gate: the resolver can never return a sibling pane's session. - -### Work package 3 — Codex transcript adapter - -- Reuse the current bounded native-chat Codex reader. -- Add deterministic page conversion and parsing warnings. -- Enforce entry and byte limits. -- Produce a path-free source identity digest and cursor. -- Add Claude only if it follows this same path without new infrastructure. - -Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path. - -### Work package 4 — Local `worker-read` - -- Resolve and validate the exact worker. -- Implement `auto`, `transcript`, and `terminal`. -- Pin the source across cursor pages. -- Preserve the existing terminal fallback and non-JSON rendering. - -Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex -sessions. - -### Work package 5 — Federated `worker-read` - -- Add the narrow connected-server capability. -- Route transcript resolution and reading to the worker server. -- Wrap legacy remote terminal responses as labeled fallbacks. -- Reject mismatched Dispatch/server/session cursors. -- Ensure paths and internal server identity remain out of ordinary output. - -Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass. - -### Work package 6 — Restart, fallback, and documentation - -- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable - transcript, unsupported providers, and mixed server versions. -- Update CLI help, the orchestration skill, and the implementation checklist. -- Dogfood the common coordinator loop using only the documented commands. - -Exit gate: every fallback is truthful and no fallback changes worker lifecycle state. - -## Validation plan - -### Unit and contract tests - -| Area | Required proof | -| --------------------- | ------------------------------------------------------------------------------------ | -| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. | -| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. | -| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. | -| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. | -| Cursor pinning | Continued pages stay on the same source and provider session. | -| Session replacement | An old cursor returns `source_changed`. | -| Cursor custody | A cursor for another Dispatch is rejected. | -| Parsing | Malformed/skipped transcript records produce bounded warnings. | -| Limits | Entry count, block size, and total serialized response are bounded. | -| Privacy | Responses and cursors contain no transcript path. | -| Compatibility | Existing terminal fields and numeric cursors continue to work. | - -Likely focused test locations: - -- provider-session normalization and pane association tests; -- native-chat transcript reader tests; -- orchestration worker-control RPC tests; -- orchestration worker CLI tests; and -- federation protocol and physical harness tests. - -### Local integration scenarios - -1. Start a Codex worker and confirm the sidebar reports its provider session. -2. Read the Dispatch and verify `source=transcript`. -3. Start two Codex workers in the same worktree. -4. Give them distinct prompts and verify neither read contains the other's content. -5. Page both transcripts and verify stable source identities. -6. replace or resume one session and verify its old cursor returns `source_changed`. -7. Disable hooks and verify a labeled terminal fallback. -8. Remove or make the transcript unreadable and verify a safe fallback or - `transcript_required`, depending on the requested source. - -### Physical federation matrix - -| Run home | Worker server | Worker location | Required outcome | -| -------- | ------------- | --------------- | --------------------------------------------------------------- | -| macOS | Windows | native Windows | Exact structured page or labeled supported fallback | -| Windows | macOS | native macOS | Exact structured page or labeled supported fallback | -| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage | -| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage | - -For both Mac/Windows directions: - -- run multiple workers at once; -- page beyond the first response; -- restart the Run home; -- restart the worker server; -- disconnect and reconnect the server; -- verify mixed-version fallback with one server lacking the new capability; and -- compare the selected provider session with the sidebar/native-chat session for the same pane. - -### Dogfood procedure - -The dogfood is successful only if a coordinator can follow this loop without internal IDs: - -1. Create/use a Run. -2. Start one local worker and one connected-server worker. -3. Wait for both starts to settle. -4. Call `worker-read --source auto` for each Dispatch. -5. Continue each cursor through at least two pages. -6. Confirm output belongs to the correct prompt and machine. -7. Trigger one fallback case. -8. Complete both workers and confirm reads never altered lifecycle state. - -Record: - -- command and response; -- chosen source and fallback reason; -- worker server/platform; -- provider and session match; -- cursor behavior; -- path-leak check; -- restart/disconnect outcome; and -- any agent confusion using only CLI help and the orchestration skill. - -## Acceptance criteria - -Implementation is complete only when: - -- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback. -- No test or dogfood scenario reads a sibling or previous provider session. -- A cursor never switches source or provider session silently. -- Mac-to-Windows and Windows-to-Mac physical reads pass. -- Runtime restart and disconnect behavior are safe and understandable. -- Unsupported agents and mixed versions retain useful terminal output. -- Transcript paths never leave the server that owns them. -- Existing terminal-read clients remain compatible. -- The common agent path remains one command with no server/session/path inputs. -- No UI, scheduling, retry, integration tracking, or generalized provider framework is added. - -## Explicit non-goals - -- No dashboard or sidebar changes. -- No coordinator chat changes. -- No automatic worker placement, retry, replacement, or recovery. -- No commit, test, branch, merge, or integration tracking. -- No provider-session locking or resume orchestration. -- No live transcript subscription in the orchestration API. -- No universal transcript/event ontology. -- No cross-server filesystem access from the Run home. -- No replicated Run database or automatic Run-home failover. -- No generalized access-control or capability framework. - -## Main risks and mitigations - -| Risk | Mitigation | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. | -| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. | -| A session changes between pages | Pin source identity in the cursor and return `source_changed`. | -| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. | -| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. | -| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. | -| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. | -| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. | -| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. | - -## Decision - -Implement structured worker output as a narrow extension of `worker-read`. - -The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped -provider sessions. The work is to make that existing association available to the worker-owning -runtime, read the transcript locally through proven readers, pin pagination to that source, and -federate only the bounded result. diff --git a/README.md b/README.md index 0c8fb045410..b0ef0741e4b 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** All other groups are full, now we're on group 5. +- **WeChat:** If group 5 is full, you can join group 6. - WeChat QR code for the Orca community + WeChat group 5 QR code for the Orca community + WeChat group 6 QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e731cb7f1d8..0a96abb6d24 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -83,12 +83,17 @@ module.exports = { '!skill-guides{,/**/*}', '!skill-stubs{,/**/*}', '!tests{,/**/*}', + // Why: examples/ is plugin authoring documentation with no runtime consumer — + // bundled plugins ship via extraResources from resources/plugins/launch/. It also + // carries hostile-panel, the adversarial fixture the containment tests point at, + // which must never reach a user's install. + '!examples{,/**/*}', // Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE); // it is gitignored, but exclude it defensively so a stray local capture at // package time never bloats app.asar. '!pr-evidence{,/**/*}', '!Casks{,/**/*}', - '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', + '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}', '!out/**/*.test.js', // Why: Vite's manifest is only used to project the paired web client. '!out/renderer/.vite{,/**/*}', diff --git a/config/oxlint-code-quality.json b/config/oxlint-code-quality-native-plugins.json similarity index 54% rename from config/oxlint-code-quality.json rename to config/oxlint-code-quality-native-plugins.json index 3ab7f3baed0..5d9cd118e74 100644 --- a/config/oxlint-code-quality.json +++ b/config/oxlint-code-quality-native-plugins.json @@ -1,6 +1,6 @@ { "$schema": "../node_modules/oxlint/configuration_schema.json", - "plugins": ["import", "jsx-a11y", "promise", "react", "react-hooks", "typescript", "vitest"], + "plugins": ["import", "jsx-a11y", "react-hooks", "vitest"], "categories": { "correctness": "off", "suspicious": "off", @@ -10,34 +10,13 @@ "restriction": "off", "nursery": "off" }, - "options": { - "reportUnusedDisableDirectives": "warn" - }, - "jsPlugins": [ - { - "name": "app-store-performance", - "specifier": "./oxlint-plugins/app-store-performance.mjs" - } - ], "rules": { - "app-store-performance/require-selector": "warn", - "app-store-performance/no-identity-selector": "warn", - "app-store-performance/no-fresh-selector-result": "warn", "import/export": "warn", "import/named": "warn", "import/namespace": "warn", "import/no-cycle": ["warn", { "maxDepth": 3 }], "import/no-duplicates": "warn", - "import/no-self-import": "warn", - "max-params": ["warn", 5], - "no-fallthrough": "warn", - "no-loop-func": "warn", - "no-promise-executor-return": "warn", - "no-unmodified-loop-condition": "warn", - "preserve-caught-error": "warn", - "promise/no-multiple-resolved": "warn", - "react/no-unstable-nested-components": "warn", - "react/react-compiler": "warn" + "import/no-self-import": "warn" }, "overrides": [ { @@ -55,9 +34,7 @@ "rules": { "vitest/no-conditional-tests": "warn", "vitest/no-focused-tests": "warn", - "vitest/no-identical-title": "warn", - "vitest/valid-expect": "warn", - "vitest/valid-title": "warn" + "vitest/no-identical-title": "warn" } }, { diff --git a/config/oxlint-code-quality-type-aware.json b/config/oxlint-code-quality-type-aware.json index 409215908b4..4c0e01fa748 100644 --- a/config/oxlint-code-quality-type-aware.json +++ b/config/oxlint-code-quality-type-aware.json @@ -12,11 +12,20 @@ }, "rules": { "typescript/await-thenable": "warn", - "typescript/no-floating-promises": "warn", - "typescript/no-misused-promises": "warn", - "typescript/only-throw-error": "warn", "typescript/restrict-plus-operands": "warn", - "typescript/restrict-template-expressions": "warn" + "typescript/restrict-template-expressions": "warn", + "typescript/switch-exhaustiveness-check": [ + "error", + { "allowDefaultCaseForExhaustiveSwitch": false } + ] }, + "overrides": [ + { + "files": ["**/*.test.*", "**/*.spec.*"], + "rules": { + "typescript/await-thenable": "off" + } + } + ], "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] } diff --git a/config/oxlint-plugins/quadratic-buffer-concat.mjs b/config/oxlint-plugins/quadratic-buffer-concat.mjs new file mode 100644 index 00000000000..b2399345f5b --- /dev/null +++ b/config/oxlint-plugins/quadratic-buffer-concat.mjs @@ -0,0 +1,266 @@ +const LOOP_TYPES = new Set([ + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'WhileStatement', + 'DoWhileStatement' +]) +const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '??=', '||=', '&&=']) +const EXPRESSION_WRAPPERS = new Set([ + 'ChainExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TypeCastExpression' +]) + +function normalizeReferenceText(text) { + return text.replaceAll(/\s+/g, '') +} + +function sourceText(context, node) { + return context.sourceCode.getText(node) +} + +function memberPropertyName(node) { + if (node?.type !== 'MemberExpression') { + return null + } + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name + } + return node.property.type === 'Literal' && typeof node.property.value === 'string' + ? node.property.value + : null +} + +function rootReferenceText(context, node) { + if (node?.type === 'Identifier') { + return node.name + } + if (node?.type === 'MemberExpression') { + return node.object.type === 'ThisExpression' + ? normalizeReferenceText(sourceText(context, node)) + : rootReferenceText(context, node.object) + } + if (node?.type === 'CallExpression') { + return rootReferenceText(context, node.callee) + } + if (EXPRESSION_WRAPPERS.has(node?.type)) { + return rootReferenceText(context, node.expression) + } + return null +} + +function isBufferConcatCall(node) { + return ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Buffer' && + memberPropertyName(node.callee) === 'concat' && + node.arguments[0]?.type === 'ArrayExpression' + ) +} + +function enclosingLoop(node) { + for (let current = node.parent; current; current = current.parent) { + if (LOOP_TYPES.has(current.type)) { + return current + } + } + return null +} + +function nodeStart(node) { + return node.start ?? node.range?.[0] ?? 0 +} + +function nodeEnd(node) { + return node.end ?? node.range?.[1] ?? 0 +} + +function isDeclaredInsideLoop(declarationStart, loop) { + if (declarationStart < nodeStart(loop) || declarationStart >= nodeEnd(loop)) { + return false + } + if (loop.type !== 'ForStatement' || !loop.init) { + return true + } + return declarationStart < nodeStart(loop.init) || declarationStart >= nodeEnd(loop.init) +} + +function collectBindingNames(pattern, names) { + if (!pattern) { + return + } + if (pattern.type === 'Identifier') { + names.push(pattern.name) + } else if (pattern.type === 'RestElement') { + collectBindingNames(pattern.argument, names) + } else if (pattern.type === 'AssignmentPattern') { + collectBindingNames(pattern.left, names) + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + collectBindingNames( + property.type === 'RestElement' ? property.argument : property.value, + names + ) + } + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + collectBindingNames(element, names) + } + } +} + +function visitChildren(node, visit) { + for (const [key, child] of Object.entries(node)) { + if (['parent', 'loc', 'range'].includes(key)) { + continue + } + if (Array.isArray(child)) { + for (const item of child) { + if (item?.type) { + visit(item) + } + } + } else if (child?.type) { + visit(child) + } + } +} + +function collectAssignedRoots(context, loop) { + const assigned = new Set() + const visit = (node) => { + if (node.type === 'AssignmentExpression' && ASSIGNMENT_OPERATORS.has(node.operator)) { + const root = rootReferenceText(context, node.left) + if (root) { + assigned.add(root) + } + } + visitChildren(node, visit) + } + visit(loop.body) + return assigned +} + +function assignmentTargetOf(context, call) { + let node = call + let parent = node.parent + while ( + parent && + (EXPRESSION_WRAPPERS.has(parent.type) || + (parent.type === 'ConditionalExpression' && parent.test !== node)) + ) { + node = parent + parent = parent.parent + } + if (parent?.type !== 'AssignmentExpression' || parent.operator !== '=' || parent.right !== node) { + return null + } + return { + text: normalizeReferenceText(sourceText(context, parent.left)), + root: rootReferenceText(context, parent.left) + } +} + +function concatOperands(context, call) { + return call.arguments[0].elements.filter(Boolean).map((element) => { + const spread = element.type === 'SpreadElement' + const expression = spread ? element.argument : element + return { + spread, + text: normalizeReferenceText(sourceText(context, expression)), + root: rootReferenceText(context, expression) + } + }) +} + +function isLoopCarried(root, loop, declarations) { + const starts = declarations.get(root) + return !starts || !starts.some((start) => isDeclaredInsideLoop(start, loop)) +} + +function quadraticAccumulator(context, call, loop, declarations, assignedRoots) { + const operands = concatOperands(context, call) + const target = assignmentTargetOf(context, call) + const selfOperand = target + ? operands.find((operand) => operand.text === target.text || operand.root === target.root) + : null + if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) { + return target.text + } + + for (const operand of operands) { + if ( + !operand.spread && + operand.root && + assignedRoots.has(operand.root) && + isLoopCarried(operand.root, loop, declarations) + ) { + return operand.root + } + } + return null +} + +function createRule(context) { + const declarations = new Map() + const assignedRootsByLoop = new WeakMap() + const recordBindings = (pattern, owner) => { + const names = [] + collectBindingNames(pattern, names) + for (const name of names) { + const starts = declarations.get(name) ?? [] + starts.push(nodeStart(owner)) + declarations.set(name, starts) + } + } + const recordParameters = (node) => { + for (const parameter of node.params) { + recordBindings(parameter, parameter) + } + } + + return { + VariableDeclarator(node) { + recordBindings(node.id, node) + }, + FunctionDeclaration: recordParameters, + FunctionExpression: recordParameters, + ArrowFunctionExpression: recordParameters, + CatchClause(node) { + recordBindings(node.param, node.param) + }, + CallExpression(node) { + if (!isBufferConcatCall(node)) { + return + } + const loop = enclosingLoop(node) + if (!loop) { + return + } + let assignedRoots = assignedRootsByLoop.get(loop) + if (!assignedRoots) { + assignedRoots = collectAssignedRoots(context, loop) + assignedRootsByLoop.set(loop, assignedRoots) + } + const accumulator = quadraticAccumulator(context, node, loop, declarations, assignedRoots) + if (accumulator) { + context.report({ + node, + message: `Buffer.concat rebuilds loop-carried ${accumulator}; collect chunks and concatenate once after the loop.` + }) + } + } + } +} + +export default { + meta: { name: 'quadratic-buffer-concat' }, + rules: { + 'no-loop-carried-concat': { create: createRule } + } +} diff --git a/config/oxlint-plugins/renderer-scrollbar-style.mjs b/config/oxlint-plugins/renderer-scrollbar-style.mjs new file mode 100644 index 00000000000..4938d66ef79 --- /dev/null +++ b/config/oxlint-plugins/renderer-scrollbar-style.mjs @@ -0,0 +1,292 @@ +const STYLED_SCROLLBAR_CLASSES = new Set([ + 'scrollbar-sleek', + 'scrollbar-editor', + 'worktree-sidebar-scrollbar' +]) +const VERTICAL_SCROLL_CLASSES = new Set([ + 'overflow-auto', + 'overflow-scroll', + 'overflow-y-auto', + 'overflow-y-scroll' +]) +const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll']) + +function withoutImportantModifier(className) { + const withoutPrefix = className.startsWith('!') ? className.slice(1) : className + return withoutPrefix.endsWith('!') ? withoutPrefix.slice(0, -1) : withoutPrefix +} + +export function plainClassName(token) { + const normalizedToken = token.startsWith('!') ? token.slice(1) : token + const parts = [] + let bracketDepth = 0 + let currentPart = '' + + for (const char of normalizedToken) { + if (char === '[') { + bracketDepth += 1 + } else if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1) + } + if (char === ':' && bracketDepth === 0) { + parts.push(currentPart) + currentPart = '' + } else { + currentPart += char + } + } + + parts.push(currentPart) + return withoutImportantModifier(parts.at(-1) ?? '') +} + +function classTokenParts(token) { + const variants = [] + let bracketDepth = 0 + let currentPart = '' + + for (const char of token.startsWith('!') ? token.slice(1) : token) { + if (char === '[') { + bracketDepth += 1 + } else if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1) + } + if (char === ':' && bracketDepth === 0) { + variants.push(currentPart) + currentPart = '' + } else { + currentPart += char + } + } + + return { className: withoutImportantModifier(currentPart), variants: variants.filter(Boolean) } +} + +function classTokens(text) { + return text.split(/\s+/).filter(Boolean).map(classTokenParts) +} + +function sameVariants(left, right) { + return left.length === right.length && left.every((variant, index) => variant === right[index]) +} + +function literalHasScrollbarForVertical(text, verticalToken) { + return classTokens(text).some( + (candidate) => + STYLED_SCROLLBAR_CLASSES.has(candidate.className) && + (candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants)) + ) +} + +function uncoveredVerticalClass(text) { + return classTokens(text).find( + (token) => + VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token) + ) +} + +function stringLiteralTexts(node) { + if (node?.type === 'Literal' && typeof node.value === 'string') { + return [node.value] + } + if (node?.type !== 'TemplateLiteral') { + return [] + } + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw) +} + +function visitChildren(node, visit) { + for (const [key, child] of Object.entries(node)) { + if (['parent', 'loc', 'range'].includes(key)) { + continue + } + if (Array.isArray(child)) { + for (const item of child) { + if (item?.type) { + visit(item) + } + } + } else if (child?.type) { + visit(child) + } + } +} + +function collectClassLiteralReports(node) { + const reports = [] + const visit = (current) => { + for (const text of stringLiteralTexts(current)) { + const uncovered = uncoveredVerticalClass(text) + if (uncovered) { + reports.push({ node: current, detail: uncovered.className }) + } + } + visitChildren(current, visit) + } + visit(node) + return reports +} + +function expressionHasStyledScrollbarLiteral(node) { + let found = false + const visit = (current) => { + if (found || current.type === 'ConditionalExpression' || current.type === 'LogicalExpression') { + return + } + found = stringLiteralTexts(current).some((text) => + classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className)) + ) + if (!found) { + visitChildren(current, visit) + } + } + visit(node) + return found +} + +function propertyName(node) { + if (node?.type !== 'Property') { + return null + } + if (!node.computed && node.key.type === 'Identifier') { + return node.key.name + } + return node.key.type === 'Literal' && typeof node.key.value === 'string' ? node.key.value : null +} + +function styleValueIsVerticalScroll(name, value) { + const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean) + if (parts.length === 0) { + return false + } + if (name === 'overflowY' || name === 'overflow-y') { + return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0]) + } + if (name !== 'overflow') { + return false + } + return VERTICAL_SCROLL_STYLE_VALUES.has(parts.length > 1 ? parts[1] : parts[0]) +} + +function collectStyleReports(node) { + const reports = [] + const visit = (current) => { + if (current.type === 'Property') { + const name = propertyName(current) + for (const value of name ? stringLiteralTexts(current.value) : []) { + if (styleValueIsVerticalScroll(name, value)) { + reports.push({ node: current, detail: 'inline vertical scroll' }) + } + } + visit(current.value) + return + } + visitChildren(current, visit) + } + visit(node) + return reports +} + +function unwrapExpression(node) { + if ( + ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression'].includes( + node?.type + ) + ) { + return unwrapExpression(node.expression) + } + return node +} + +function spreadPropExpressions(expression, propName) { + const node = unwrapExpression(expression) + if (!node) { + return [] + } + if (node.type === 'ConditionalExpression') { + return [ + ...spreadPropExpressions(node.consequent, propName), + ...spreadPropExpressions(node.alternate, propName) + ] + } + if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') { + return [ + ...spreadPropExpressions(node.left, propName), + ...spreadPropExpressions(node.right, propName) + ] + } + if (node.type !== 'ObjectExpression') { + return [] + } + return node.properties.flatMap((property) => { + if (property.type === 'SpreadElement') { + return spreadPropExpressions(property.argument, propName) + } + return propertyName(property) === propName ? [property.value] : [] + }) +} + +function jsxAttributeExpression(attribute) { + if (attribute.value?.type === 'Literal') { + return attribute.value + } + return attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : null +} + +function jsxElementReports(node) { + let classExpression = null + const styleExpressions = [] + + for (const attribute of node.attributes) { + if (attribute.type === 'JSXSpreadAttribute') { + const spreadClassExpression = spreadPropExpressions(attribute.argument, 'className').at(-1) + if (spreadClassExpression) { + classExpression = spreadClassExpression + } + styleExpressions.push(...spreadPropExpressions(attribute.argument, 'style')) + } else if (attribute.name?.name === 'className') { + classExpression = jsxAttributeExpression(attribute) + } else if (attribute.name?.name === 'style') { + const expression = jsxAttributeExpression(attribute) + if (expression) { + styleExpressions.push(expression) + } + } + } + + const reports = classExpression ? collectClassLiteralReports(classExpression) : [] + if (!classExpression || !expressionHasStyledScrollbarLiteral(classExpression)) { + for (const expression of styleExpressions) { + reports.push(...collectStyleReports(expression)) + } + } + return reports +} + +function bindContext(createVisitors) { + return (context) => { + const visitors = createVisitors() + for (const [nodeType, visit] of Object.entries(visitors)) { + visitors[nodeType] = visit.bind(context) + } + return visitors + } +} + +export default { + meta: { name: 'renderer-scrollbar-style' }, + rules: { + 'require-styled-vertical-scrollbar': { + create: bindContext(() => ({ + JSXOpeningElement(node) { + for (const report of jsxElementReports(node)) { + this.report({ + node: report.node, + message: `Vertical scroll container (${report.detail}) must use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.` + }) + } + } + })) + } + } +} diff --git a/config/oxlint-switch-exhaustiveness.json b/config/oxlint-switch-exhaustiveness.json deleted file mode 100644 index c967a7b9b5d..00000000000 --- a/config/oxlint-switch-exhaustiveness.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "../node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript"], - "categories": { - "correctness": "off", - "suspicious": "off", - "pedantic": "off", - "perf": "off", - "style": "off", - "restriction": "off", - "nursery": "off" - }, - "rules": { - "typescript/switch-exhaustiveness-check": [ - "error", - { "allowDefaultCaseForExhaustiveSwitch": false } - ] - } -} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ba6ecb6cf91..f5aa19d93e7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-26", + "updatedAt": "2026-07-28", "policy": { "maturityLevels": [ "experimental", @@ -253,7 +253,11 @@ "surfaces": [ "terminal input availability", "worktree discovery", - "Source Control status refresh" + "Source Control status refresh", + "direct SSH detected-worktree scheduling", + "direct SSH reconnect telemetry", + "direct SSH host catalog authority", + "direct SSH generation-scope rollover" ], "platforms": [ "macos", @@ -271,14 +275,17 @@ "local", "ssh" ], - "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, and Source Control active-visible repo filtering. Linux/Windows are covered at the shared poller layer by forcing the non-darwin path; live platform runs remain gaps.", + "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A macOS Electron client completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, paired-client, and WSL runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7086" ], - "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout.", - "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters.", + "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", + "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents.test.ts --reporter=dot", + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -287,7 +294,15 @@ "src/main/ipc/worktree-head-identity-reader.test.ts", "src/renderer/src/hooks/worktree-head-identity-apply.test.ts", "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts", - "src/renderer/src/hooks/useIpcEvents.test.ts" + "src/renderer/src/hooks/useIpcEvents.test.ts", + "src/main/ipc/repos-remote.test.ts", + "src/main/ssh/ssh-connection-generation.test.ts", + "src/main/ssh/ssh-provider-authority.test.ts", + "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", + "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", + "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ { @@ -343,7 +358,66 @@ { "file": "src/renderer/src/hooks/useIpcEvents.test.ts", "assertions": [ - "renderer preload API fixtures include the status-metadata and head-identity subscription contracts" + "renderer preload API fixtures include the status-metadata and head-identity subscription contracts", + "direct SSH coordinator telemetry is wired through the fail-soft product adapter" + ] + }, + { + "file": "src/main/ipc/repos-remote.test.ts", + "assertions": [ + "a host-qualified catalog rejects contradictory executionHostId and connectionId provenance without returning rows", + "local, sibling SSH, and runtime rows remain excluded from the exact direct SSH catalog" + ] + }, + { + "file": "src/main/ssh/ssh-connection-generation.test.ts", + "assertions": [ + "one exhausted target rolls the process generation scope and invalidates every sibling target token", + "old-scope mutation expectations fail while the new-scope authority continues rotating" + ] + }, + { + "file": "src/main/ssh/ssh-provider-authority.test.ts", + "assertions": [ + "generation-scope rollover invalidates every target authority before abort callbacks run", + "every registered old-scope provider request aborts exactly once" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", + "assertions": [ + "coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin", + "the first timeout remains retrying and reports queue wait separately from provider execution", + "cancel debt admits at most two replacements and terminally distinguishes budget exhaustion" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "assertions": [ + "lineage and token creation remain blocked until a timed-out repo retry settles", + "exact overlapping preparation emits one aggregate with a join count", + "telemetry callback failure cannot affect reconnect completion" + ] + }, + { + "file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", + "assertions": [ + "timeout, rejection, cancellation, and stale outcomes have independent fields", + "target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected" + ] + }, + { + "file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", + "assertions": [ + "one coordinator aggregate maps to one typed product event with queue and provider percentiles", + "adapter failure is swallowed before it can reach recovery" + ] + }, + { + "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", + "assertions": [ + "repo and worktree hydration use the exact direct SSH host and complete provider authority", + "terminal input remains live after disconnect/reconnect and writes a proof file visible inside the Linux target" ] } ], @@ -356,23 +430,50 @@ "result": "passed", "durationSeconds": 3.12, "summary": "7 files and 130 tests passed locally, adding head-identity emit-on-change without structural fanout, reflog status triggers, config.worktree structural classification, the in-place index backstop, and the spawn-free head reader with symref traversal rejection and hex-object-id output validation." + }, + { + "date": "2026-07-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.61, + "summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation." + }, + { + "date": "2026-07-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 3.45, + "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." + }, + { + "date": "2026-07-27", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 66, + "summary": "Four Electron Docker SSH tests passed: two typing/performance paths, one concurrent file/Git load path, and exact-authority disconnect/reconnect with a container-visible remote proof file." } ], "runtimeBudget": { - "p95Seconds": 10, - "scope": "focused main/preload/renderer unit and polling tests" + "p95Seconds": 15, + "scope": "focused main/preload/renderer polling and direct SSH scheduler/telemetry tests" }, "flakeHistory": { "status": "unknown", - "evidence": "New experimental deterministic gate with one local macOS run; needs CI soak before promotion." + "evidence": "Three deterministic local macOS runs cover the original watcher lane, main catalog/authority lane, and direct SSH scheduler/telemetry lane; CI soak is still unavailable." }, "redGreenEvidence": { "status": "partial", - "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. Saved CI red/green artifacts are still needed before blocking promotion." + "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." }, "performanceBudget": { "required": true, - "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping, compares HEAD/gitdir/locked signatures every tick, and gates linked index inspection behind the entry-directory signature. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; a locked create/delete positive control still caused structural refreshes." + "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed." }, "promotionCriteria": [ "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", @@ -383,7 +484,10 @@ "The live Electron diagnostic and screenshot evidence must remain attached to the motivating PR for durable review.", "Linux and Windows are forced through the shared non-darwin poller in unit tests but are not live-tested here.", "Git loose ref watching remains outside this incident fix by design.", - "SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation." + "SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation.", + "The Docker/Linux journey covers one direct SSH target; a live multi-target fanout and large-catalog benchmark remains missing.", + "Paired web clients intentionally do not run the desktop direct SSH coordinator, and paired-close non-interference lacks a new live run.", + "WSL direct SSH fanout remains an explicit live-test gap rather than inferred coverage." ], "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." }, @@ -3567,6 +3671,10 @@ "layer": "provider-contract", "surfaces": [ "SSH deferred restore", + "direct SSH reconnect finalization", + "direct SSH folder workspace reattach", + "direct SSH split-pane retry ownership", + "same-authority terminal correction", "remote-runtime mirror polling", "remote-runtime network recovery", "terminal create idempotency", @@ -3591,7 +3699,7 @@ "ssh", "remote-runtime" ], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Capability-gated create retries adopt a provider-owned PTY by stable terminal identity after an unknown outcome or runtime-process restart, stop retrying after one minute without a fatal error, and remain manually retryable without accepting stale create completions. A macOS Electron journey covers live SSH restore; a Windows remote-runtime smoke covers reachability and PTY round-trip. A live partition journey using patched Mac and Windows builds remains a gap.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -3599,14 +3707,18 @@ "https://github.com/stablyai/orca/pull/7009", "https://github.com/stablyai/orca/pull/8597" ], - "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", - "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, and manual reconnect. They assert one unsubscribe per epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, one-minute create-retry cutoff, old-runtime no-retry behavior, authenticated client/worktree isolation, cross-process PTY adoption without rerunning startup, unavailable or legacy-incomplete inventory fail-closed behavior, and bounded in-flight coordination. Existing count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind.", + "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", + "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/startup/ssh-startup-reconnect.test.ts", @@ -3615,6 +3727,7 @@ "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts", "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx", @@ -3629,8 +3742,32 @@ "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts", "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts", "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts", + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts", + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts", + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", + "src/renderer/src/store/slices/terminals-hydration.test.ts", + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", + "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", + "src/main/ipc/repos-remote.test.ts", + "src/main/ipc/ssh.test.ts", + "src/main/ipc/worktrees.test.ts", + "src/main/runtime/public-ssh-state.test.ts", + "src/main/ssh/ssh-connection-manager.test.ts", + "src/main/ssh/ssh-connection.test.ts", + "src/main/ssh/ssh-provider-authority.test.ts", + "src/preload/ssh-authority-forwarding.test.ts", + "src/renderer/src/runtime/runtime-client-events.test.ts", + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts", + "src/shared/ssh-retained-payload-admission.test.ts", + "src/shared/ssh-types.test.ts", "tests/e2e/terminal-cold-activation-deferral.spec.ts", - "tests/e2e/ssh-cold-activation-restore.spec.ts" + "tests/e2e/ssh-cold-activation-restore.spec.ts", + "tests/e2e/ssh-docker-relay-perf.spec.ts" ], "assertionRefs": [ { @@ -3720,6 +3857,189 @@ "snapshot-first replacement still migrates stale PTY-indexed state" ] }, + { + "file": "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts", + "assertions": [ + "one atomic patch clears exact-target live PTY indexes while preserving relay identity and null-PTY activation", + "another SSH target, local, WSL, floating, and runtime-owned terminal state remains unchanged" + ] + }, + { + "file": "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", + "assertions": [ + "Git and folder workspaces resolve only from consistent exact-target provenance", + "ambiguous, contradictory, mixed, and runtime-owned folders fail closed" + ] + }, + { + "file": "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts", + "assertions": [ + "one authority chain permits at most two automatic attempts even when both timeouts exceed the rolling thirty-second window", + "rejected stale-authority, stale-attempt, or pre-commit success acknowledgements mutate none of the tab, PTY-index, pending, history, or live-binding maps", + "both split-pane siblings bind under one exact attempt while the first PTY remains the tab fallback", + "a sibling failure starts one second tab-wide attempt and stale first-attempt callbacks preserve it", + "an exhausted attempt retains sibling continuation authority and promotes a surviving primary PTY without attempt three", + "primary exit before sibling commit preserves the exact continuation lease and accepts the late sibling" + ] + }, + { + "file": "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts", + "assertions": [ + "primary and non-primary split detach preserve exact live authority and retry history on both resulting tabs", + "detaching the only bound split while its sibling is still spawning preserves the source continuation lease until that sibling binds", + "detaching during a null-PTY continuation gap projects the exact lease to both pending tabs", + "a pending-only all-null detach preserves the exact lease on both tabs before either leaf binds", + "same-authority invalidation and correction leave both detached live PTYs unchanged" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", + "assertions": [ + "a detached null-PTY leaf remains marked for pending activation before ownership transfer" + ] + }, + { + "file": "src/main/ipc/repos-remote.test.ts", + "assertions": [ + "host-qualified repo catalogs require one consistent execution host and complete current SSH authority", + "contradictory, partial, mismatched, stale, and runtime-owned catalog requests fail closed" + ] + }, + { + "file": "src/main/ipc/ssh.test.ts", + "assertions": [ + "concurrent same-authority connects share one provider attempt", + "authority rotation starts stale transport cancellation before teardown, concurrent fresh callers share one replacement, and stale completion cannot clobber the fresh session", + "same-turn disconnect and forward-teardown failures across removal, reset, and terminate keep replacement connects and metadata mutation behind complete target cleanup" + ] + }, + { + "file": "src/main/ssh/ssh-connection-manager.test.ts", + "assertions": [ + "disconnect invalidates a pending transport attempt immediately so late rejection or resolution cannot remove the replacement" + ] + }, + { + "file": "src/main/ssh/ssh-connection.test.ts", + "assertions": [ + "late ssh2 ready and startup error events after disconnect cannot resurrect or overwrite disconnected state" + ] + }, + { + "file": "src/main/ipc/worktrees.test.ts", + "assertions": [ + "host-qualified worktree reads reject malformed or contradictory repo executionHostId/connectionId provenance before provider access and after provider awaits without durable mutations", + "host-qualified lineage excludes other SSH and runtime owners and rejects ambiguous or contradictory provenance", + "one lineage request snapshots repo, folder, and group catalogs once and memoizes repeated owner resolution" + ] + }, + { + "file": "src/main/runtime/public-ssh-state.test.ts", + "assertions": [ + "public SSH state preserves the complete provider epoch and connection generation pair" + ] + }, + { + "file": "src/main/ssh/ssh-provider-authority.test.ts", + "assertions": [ + "provider epoch and connection generation rotate as one exact authority pair", + "provider resolution rejects stale or incomplete authority", + "unknown-target currency probes reject without allocating provider authority state" + ] + }, + { + "file": "src/preload/ssh-authority-forwarding.test.ts", + "assertions": [ + "full authority crosses Electron IPC without loss", + "partial authority becomes unknown for bounded reconciliation and malformed full authority is dropped", + "variable-form host-qualified worktree requests retain fail-closed outcomes in their return type" + ] + }, + { + "file": "src/shared/ssh-retained-payload-admission.test.ts", + "assertions": [ + "retained connection states reject partial or malformed authority", + "only partial compatibility authority can normalize to unknown for bounded direct-SSH reconciliation", + "shared direct SSH authority admission requires bounded identifiers and a non-negative safe generation" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-client-events.test.ts", + "assertions": [ + "retained runtime snapshots and live events preserve the full pair", + "partial runtime authority is rejected before it reaches environment state" + ] + }, + { + "file": "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts", + "assertions": [ + "runtime-owned SSH state remains isolated by environment and rejects partial retained authority", + "in-flight hydration cannot resurrect disconnected or removed runtime environments" + ] + }, + { + "file": "src/shared/ssh-types.test.ts", + "assertions": [ + "SSH connection state carries the provider epoch and connection generation authority pair" + ] + }, + { + "file": "src/renderer/src/store/slices/terminals-hydration.test.ts", + "assertions": [ + "target-scoped hydration and reconnect preserve sibling SSH and runtime tabs, PTY indexes, runtime ownership, and active selection", + "authoritative target-tab deletion prunes only that tab's retry, live-binding, and retry-history ledgers", + "a snapshot PTY from another SSH host is rejected from the target scope" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", + "assertions": [ + "a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", + "assertions": [ + "terminal invalidation and retry run synchronously before provider preparation", + "hydrated terminal finalization and same-authority correction are current-authority fenced", + "rapid authority rotation keeps immediate terminal checks while damping full preparation" + ] + }, + { + "file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", + "assertions": [ + "exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows" + ] + }, + { + "file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts", + "assertions": [ + "snapshot hydration preserves newer local recovery and keeps imported PTY ids retryable until exact-attempt transport acknowledgement", + "stale operation tokens cannot apply an older snapshot over current authority", + "target snapshot projection and persisted-terminal reconnect are host-qualified and preserve sibling SSH, local, WSL, and runtime state" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "StrictMode remounts join only the same direct SSH retry attempt", + "authority rotation starts a new spawn and rejects then retires a late obsolete-authority fresh PTY", + "late stale rebind and reattach completions, including lease replacement during asynchronous SSH preparation, callback errors, rejected promises, session-expired, empty, and launch-metadata outcomes, cannot clear current state, start replacement recovery, publish errors, or publish metadata", + "both concurrent split-pane spawns commit through the same exact retry attempt", + "both concurrent split-pane reattaches commit through the same exact retry attempt", + "a sibling mounted after first success captures the retained live lease", + "authority rotation rejects and retires a delayed sibling spawned from a retained live lease", + "intentional pane disposal cancels retry settlement while a same-attempt StrictMode remount retains one timeout" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "admission rejection precedes buffered final-frame and exit publication", + "abandoning an obsolete reattach drops its data, replay, write-unavailable, and exit handlers without killing the durable PTY", + "a rejected or destroyed fresh session fallback settles retirement before it can publish handlers, shutdown refusal is reported as unknown, and reattach remains non-destructive" + ] + }, { "file": "tests/e2e/ssh-cold-activation-restore.spec.ts", "assertions": [ @@ -3727,6 +4047,13 @@ "all six SSH managers mount eagerly and none is parked", "restored terminal input reaches a proof file on the Linux SSH host" ] + }, + { + "file": "tests/e2e/ssh-docker-relay-perf.spec.ts", + "assertions": [ + "repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned", + "a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target" + ] } ], "evidenceRuns": [ @@ -3756,23 +4083,68 @@ "result": "passed", "durationSeconds": 5, "summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 15.8, + "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "result": "passed", + "durationSeconds": 1.48, + "summary": "All 90 transport tests passed, including pre-publication admission rejection, handler-complete non-destructive detach for obsolete SSH reattach transports, settled retirement of rejected or destroyed fresh fallbacks, and reported shutdown refusal." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 2.55, + "summary": "Twelve main, preload, runtime, and shared authority files and 513 tests passed, including fail-closed pre/post-await repo ownership provenance, production retained-payload admission, fenced stale-transport replacement, failure-safe target lifecycle barriers, and real ssh2 late-ready/error rejection." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 51.4, + "summary": "Four Electron Docker SSH tests passed on the final implementation, including exact-authority repo/worktree hydration, two concurrent immutable file streams under Git churn, live terminal input before and after disconnect/reconnect, and an independent container-visible remote proof file." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 12.5, + "summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target." } ], "runtimeBudget": { - "p95Seconds": 45, - "scope": "provider contract plus optional SSH soak" + "p95Seconds": 150, + "scope": "all configured provider-contract, build, Electron, Docker SSH reconnect, and six-terminal cold-restore commands" }, "flakeHistory": { "status": "unknown", - "evidence": "Focused renderer contracts and one clean Docker/Linux SSH Electron run pass locally; the live journey needs CI soak history before promotion." + "evidence": "Focused renderer contracts, one current 627-test direct SSH run, and current Docker/Linux reconnect and six-terminal cold-restore journeys pass locally. One pressure run entered reconnect while waiting for its seventh marker; later runs delivered all markers but exposed an independent disappearing-second-file fixture race. The corrected two-reader single-file load passed the complete 4/4 suite while retaining concurrent stream pressure. Live multi-target fanout still needs CI soak history before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. The bounded-recovery tests additionally failed before the policy change because PTY and terminal-create recovery remained active after one minute and a retry reused the stale epoch. Final review tests failed before lifecycle fencing because create cutoff emitted a fatal error with no manual path and a delayed create completion replaced a newer cross-runtime attachment. The fixed tests pass with resumed snapshot/output/input, one-minute cutoffs, new manual epochs, capability re-probing, stale-create rejection, and cross-process provider PTY adoption without rerunning startup. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, a patched live remote-runtime partition journey, and saved intentional-break artifacts before promotion." + "evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion." }, "performanceBudget": { "required": true, - "evidence": "Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action; regular PTYs and initial creates do not poll inventory. Each unknown-outcome create attempt performs one bounded provider inventory scan, coordinated per authenticated client/worktree mutation, and the renderer stops issuing attempts after one minute. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch and cutoff tests prove request counts stay fixed for five additional minutes. Client and server liveness each use one interval per socket/transport. At most 4,096 create promises are retained only while in flight, and capacity rejection happens before spawning. Common terminal input/output paths add only constant-time state checks; recovery UI updates only on deduplicated phase transitions." + "evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed." }, "promotionCriteria": [ "Use deterministic fake providers for failure and unknown-liveness cases.", @@ -3782,7 +4154,9 @@ "knownGaps": [ "Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.", "The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.", - "WSL restore remains inferred rather than directly covered.", + "Current Docker/Linux journeys prove one target's reconnect and cold-restore paths; live multi-target fanout, folder-workspace reconnect, and large-terminal-map timing remain untested.", + "No paired-client close/non-interference journey was run for direct SSH reconnect; paired web clients intentionally remain outside coordinator ownership.", + "WSL restore and direct SSH/WSL isolation remain inferred rather than directly covered.", "Linux and Windows desktop-client partition journeys using patched builds are not yet collected; the Windows smoke proves current reachability and PTY round-trip only.", "Terminal-create recovery depends on providers authoritatively listing live terminal handles and worktree ownership; older runtimes do not advertise the capability and are never retried after an unknown outcome." ], @@ -4759,6 +5133,147 @@ ], "demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner." }, + { + "id": "orchestration.worker-terminal-delivery", + "title": "Started workers are visible without stealing focus and retain mailbox identity", + "maturity": "experimental", + "protection": "partial", + "owner": "orchestration", + "layer": "cli-runtime-renderer-contract", + "surfaces": [ + "Run and Dispatch mailboxes", + "worker-start", + "terminal creation", + "terminal tab materialization", + "workspace re-entry" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh" + ], + "coverageNotes": "Deterministic units cover local worker presentation, reveal-failure warnings, stable-pane Run/Dispatch routing, and the SSH in-process CLI fallback. An isolated macOS Electron journey launches a fake Codex worker through the real RPC path, tolerates spawn-time handle reminting, asserts the inactive tab in the DOM before navigation, checks Run delivery by pane identity, and proves workspace re-entry keeps one worker tab by both original tab ID and visible title. SSH, WSL, remote-runtime, Linux, and Windows remain live-test gaps; federated workers retain explicit background presentation.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/11107#discussion_r3663321387" + ], + "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", + "oracle": "Drive Run create, Task create, and worker-start through a production Electron runtime with a deterministic Codex fixture. Require the worker tab to be visible in the DOM with data-active=false while the coordinator tab stays active, send ACK to the Run, read it with a deliberately stale coordinator handle plus its stable pane key, switch workspaces away and back, and require exactly one worker tab by original ID and visible title. Unit tests separately assert local worker-start omits background presentation, reveal failures return an actionable warning without discarding the worker, reminted coordinators and workers retain mailbox routing, live handles outrank mismatched pane metadata, and explicit legacy terminal checks do not inherit the caller pane key locally or through the SSH fallback.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1" + ], + "testFiles": [ + "src/cli/handlers/orchestration.test.ts", + "src/cli/handlers/orchestration-check-identity.test.ts", + "src/cli/handlers/orchestration-worker-cli.test.ts", + "src/main/runtime/rpc/methods/orchestration.test.ts", + "src/main/ssh/ssh-remote-orca-cli.test.ts", + "tests/e2e/orchestration-worker-terminal-visibility.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/cli/handlers/orchestration-check-identity.test.ts", + "assertions": [ + "implicit check carries the caller pane key with a potentially stale environment handle", + "explicit legacy terminal inspection does not inherit the caller pane key" + ] + }, + { + "file": "src/cli/handlers/orchestration-worker-cli.test.ts", + "assertions": [ + "worker-start prints an explicit warning when its live worker remains background-only" + ] + }, + { + "file": "src/main/runtime/rpc/methods/orchestration.test.ts", + "assertions": [ + "same-workspace worker creation uses visible inactive presentation", + "worker-start preserves and reports renderer reveal failures", + "Run delivery resolves through a stable coordinator pane after handle remint", + "Dispatch delivery resolves through a stable worker pane after handle remint", + "a live handle cannot be retargeted by mismatched pane metadata" + ] + }, + { + "file": "src/main/ssh/ssh-remote-orca-cli.test.ts", + "assertions": [ + "implicit SSH fallback checks retain stable pane identity", + "explicit legacy SSH inspection does not inherit the caller pane key" + ] + }, + { + "file": "tests/e2e/orchestration-worker-terminal-visibility.spec.ts", + "assertions": [ + "worker-start exposes one inactive worker tab before workspace navigation", + "the coordinator tab remains active", + "ACK delivery reaches a stable coordinator pane through a stale handle", + "workspace re-entry does not duplicate the worker tab under the same or a new tab ID" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", + "result": "passed", + "durationSeconds": 5.27, + "summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity." + }, + { + "date": "2026-07-28", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 11.5, + "summary": "The isolated Electron journey passed with a real Run and Task, deterministic Codex PTY, immediate inactive DOM tab, ACK delivery through a stale coordinator handle, and exactly one worker tab by original ID and management title after workspace re-entry." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused CLI/runtime units plus one isolated Electron worker-start journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic units and isolated Electron journey pass locally; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation, and the live incident plus Electron topology showed the worker PTY existed without an immediate tab. Saved intentional-break and CI artifacts are still needed." + }, + "performanceBudget": { + "required": true, + "evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup; it adds no extra RPC, subprocess, timer, or renderer update. Federated and explicitly background terminals are unchanged." + }, + "promotionCriteria": [ + "Collect 100 consecutive passes or 14 days of stable CI history on macOS, Linux, and Windows.", + "Add live SSH and WSL exact-workspace worker-start evidence and a paired remote-runtime control.", + "Attach saved intentional-break artifacts for hidden local presentation and dropped stable-pane delivery." + ], + "knownGaps": [ + "No live Linux, Windows, SSH, WSL, or paired remote-runtime run is attached.", + "The Electron journey uses a deterministic fake Codex CLI rather than a real account.", + "The gate does not cover terminal output recovery after app restart; it covers identity, discoverability, focus, and mailbox routing." + ], + "demotionRule": "Keep experimental or demote if the Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if workspace re-entry duplicates the tab, or if pane-stable delivery reads the wrong mailbox." + }, { "id": "terminal-render.windows-cjk-repaint", "title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells", diff --git a/config/scripts/app-store-performance-plugin.test.mjs b/config/scripts/app-store-performance-plugin.test.mjs index 344918309f8..bb2f305ba92 100644 --- a/config/scripts/app-store-performance-plugin.test.mjs +++ b/config/scripts/app-store-performance-plugin.test.mjs @@ -1,41 +1,20 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' const pluginPath = path.resolve('config/oxlint-plugins/app-store-performance.mjs') -const oxlintPath = path.resolve( - process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint' -) function lintSource(source) { - const directory = mkdtempSync(path.join(tmpdir(), 'orca-app-store-lint-')) - const sourcePath = path.join(directory, 'sample.tsx') - const configPath = path.join(directory, 'oxlint.json') - writeFileSync(sourcePath, source) - writeFileSync( - configPath, - JSON.stringify({ - categories: { - correctness: 'off' - }, - jsPlugins: [{ name: 'app-store-performance', specifier: pluginPath }], - rules: { - 'app-store-performance/require-selector': 'warn', - 'app-store-performance/no-identity-selector': 'warn', - 'app-store-performance/no-fresh-selector-result': 'warn' - } - }) - ) - const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], { - encoding: 'utf8' + return runOxlintPluginOnSource({ + pluginName: 'app-store-performance', + pluginPath, + source, + rules: { + 'app-store-performance/require-selector': 'warn', + 'app-store-performance/no-identity-selector': 'warn', + 'app-store-performance/no-fresh-selector-result': 'warn' + } }) - if (result.error) { - throw result.error - } - expect(result.status).toBe(0) - return JSON.parse(result.stdout).diagnostics } describe('app store performance Oxlint plugin', () => { diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 7efcbe480f1..2a3542e78b9 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -6,15 +6,12 @@ import { pathToFileURL } from 'node:url' import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs' const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ -const OXLINT_SCANS = [ +export const OXLINT_SCANS = [ { + // Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root + // config would apply root rules to mobile/, whose .oxlintrc.json turns them off. label: 'code quality', - args: [ - '--config', - 'config/oxlint-code-quality.json', - '--report-unused-disable-directives-severity', - 'warn' - ] + args: ['--report-unused-disable-directives-severity', 'warn'] }, { label: 'type-aware code quality', diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 0f68172a5b5..5daca8da49e 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + OXLINT_SCANS, diagnosticTouchesAddedLines, overlapsAddedLines, parseAddedLineRanges @@ -41,4 +42,13 @@ describe('changed-code quality line matching', () => { diagnosticTouchesAddedLines(diagnostic, new Map([[file, [{ start: 24, end: 24 }]]]), root) ).toBe(true) }) + + // Why: pinning --config disables nested-config discovery, so root rules that + // mobile/.oxlintrc.json turns off would fail the gate on mobile files. + it('lets the untyped scan discover nested configs instead of pinning the root config', () => { + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality') + + expect(scan.args).not.toContain('--config') + expect(scan.args).not.toContain('--disable-nested-config') + }) }) diff --git a/config/scripts/check-quadratic-buffer-concat.mjs b/config/scripts/check-quadratic-buffer-concat.mjs deleted file mode 100644 index 611157c3eaa..00000000000 --- a/config/scripts/check-quadratic-buffer-concat.mjs +++ /dev/null @@ -1,343 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import process from 'node:process' - -// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. -import ts from 'typescript-api' - -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts']) -const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) -const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile'] - -const LOOP_KINDS = new Set([ - ts.SyntaxKind.ForStatement, - ts.SyntaxKind.ForInStatement, - ts.SyntaxKind.ForOfStatement, - ts.SyntaxKind.WhileStatement, - ts.SyntaxKind.DoStatement -]) - -const ASSIGNMENT_OPERATORS = new Set([ - ts.SyntaxKind.EqualsToken, - ts.SyntaxKind.PlusEqualsToken, - ts.SyntaxKind.QuestionQuestionEqualsToken, - ts.SyntaxKind.BarBarEqualsToken, - ts.SyntaxKind.AmpersandAmpersandEqualsToken -]) - -function normalizeReferenceText(text) { - return text.replaceAll(/\s+/g, '') -} - -// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)` -// still resolves to `carry`. `this.x` stops at the property: the class field is -// the accumulator. -function rootReferenceText(node) { - if (ts.isIdentifier(node)) { - return node.text - } - if (ts.isPropertyAccessExpression(node)) { - return node.expression.kind === ts.SyntaxKind.ThisKeyword - ? normalizeReferenceText(node.getText()) - : rootReferenceText(node.expression) - } - if ( - ts.isElementAccessExpression(node) || - ts.isCallExpression(node) || - ts.isNonNullExpression(node) || - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) - ) { - return rootReferenceText(node.expression) - } - return undefined -} - -function isBufferConcatCall(node) { - return ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === 'concat' && - ts.isIdentifier(node.expression.expression) && - node.expression.expression.text === 'Buffer' && - node.arguments.length > 0 && - ts.isArrayLiteralExpression(node.arguments[0]) - ) -} - -function enclosingLoop(node) { - for (let current = node.parent; current; current = current.parent) { - if (LOOP_KINDS.has(current.kind)) { - return current - } - } - return undefined -} - -// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside -// the loop node, so the initializer does not count as loop-local. -function isDeclaredInsideLoop(declarationStart, loop) { - if (declarationStart < loop.getStart() || declarationStart >= loop.end) { - return false - } - const initializer = ts.isForStatement(loop) ? loop.initializer : undefined - if (!initializer) { - return true - } - return declarationStart < initializer.getStart() || declarationStart >= initializer.end -} - -function collectBindingNames(name, into) { - if (ts.isIdentifier(name)) { - into.push(name) - return - } - if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { - for (const element of name.elements) { - if (ts.isBindingElement(element)) { - collectBindingNames(element.name, into) - } - } - } -} - -function collectDeclarations(sourceFile) { - const declarations = new Map() - const record = (identifier, node) => { - const existing = declarations.get(identifier.text) - const start = node.getStart(sourceFile) - if (existing) { - existing.push(start) - } else { - declarations.set(identifier.text, [start]) - } - } - - const visit = (node) => { - if (ts.isVariableDeclaration(node) || ts.isParameter(node)) { - const names = [] - collectBindingNames(node.name, names) - for (const identifier of names) { - record(identifier, node) - } - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return declarations -} - -function collectAssignedRoots(loop) { - const assigned = new Set() - const visit = (node) => { - if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) { - const root = rootReferenceText(node.left) - if (root) { - assigned.add(root) - } - } - ts.forEachChild(node, visit) - } - visit(loop.statement) - return assigned -} - -// Unwrap the wrappers a concat result passes through before it lands on the -// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts. -function assignmentTargetOf(call) { - let node = call - let parent = node.parent - while ( - parent && - (ts.isParenthesizedExpression(parent) || - ts.isAsExpression(parent) || - ts.isNonNullExpression(parent) || - (ts.isConditionalExpression(parent) && parent.condition !== node)) - ) { - node = parent - parent = parent.parent - } - if ( - parent && - ts.isBinaryExpression(parent) && - parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && - parent.right === node - ) { - return { - text: normalizeReferenceText(parent.left.getText()), - root: rootReferenceText(parent.left) - } - } - return undefined -} - -function concatOperands(call) { - return call.arguments[0].elements.map((element) => { - const spread = ts.isSpreadElement(element) - const expression = spread ? element.expression : element - return { - spread, - text: normalizeReferenceText(expression.getText()), - root: rootReferenceText(expression) - } - }) -} - -// A binding reset on every iteration cannot accumulate; only one that outlives -// the iteration carries the cost forward. -function isLoopCarried(root, loop, declarations) { - const starts = declarations.get(root) - if (!starts) { - return true - } - return !starts.some((start) => isDeclaredInsideLoop(start, loop)) -} - -function quadraticAccumulator(call, loop, declarations, assignedRoots) { - const operands = concatOperands(call) - const target = assignmentTargetOf(call) - // The result feeds straight back into its own input: every iteration re-copies - // everything accumulated so far. - const selfOperand = target - ? operands.find((operand) => operand.text === target.text || operand.root === target.root) - : undefined - if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) { - return target.text - } - - // No direct self-assignment, so look for a loop-carried operand reassigned - // inside the loop: the concat result reaches it by some other path. Spread - // operands are the sanctioned chunk-list fix, not this bug, so skip them. - for (const operand of operands) { - if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) { - continue - } - if (isLoopCarried(operand.root, loop, declarations)) { - return operand.root - } - } - return undefined -} - -export function reportQuadraticBufferConcat(filePath, sourceText) { - if (!sourceText.includes('Buffer.concat')) { - return [] - } - const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true) - const declarations = collectDeclarations(sourceFile) - const assignedRootsByLoop = new Map() - const reports = [] - const seen = new Set() - - const visit = (node) => { - if (isBufferConcatCall(node)) { - const loop = enclosingLoop(node) - if (loop) { - let assignedRoots = assignedRootsByLoop.get(loop) - if (!assignedRoots) { - assignedRoots = collectAssignedRoots(loop) - assignedRootsByLoop.set(loop, assignedRoots) - } - const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots) - if (accumulator) { - const start = node.getStart(sourceFile) - if (!seen.has(start)) { - seen.add(start) - const { line, character } = sourceFile.getLineAndCharacterOfPosition(start) - reports.push({ - filePath, - line: line + 1, - column: character + 1, - accumulator, - text: node.getText() - }) - } - } - } - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return reports -} - -export function normalizePath(root, filePath) { - return path.relative(root, filePath).split(path.sep).join('/') -} - -function isSkippedFile(root, filePath) { - const relative = normalizePath(root, filePath) - // Benchmarks keep the pre-fix shape on purpose so they can measure against it. - if ( - relative.includes('.test.') || - relative.includes('.spec.') || - relative.includes('-benchmark.') - ) { - return true - } - return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) -} - -async function collectSourceFiles(root, dir) { - let entries - try { - entries = await fs.readdir(dir, { withFileTypes: true }) - } catch { - return [] - } - const files = [] - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (!SKIP_PATH_PARTS.has(entry.name)) { - files.push(...(await collectSourceFiles(root, fullPath))) - } - } else if ( - entry.isFile() && - SOURCE_EXTENSIONS.has(path.extname(entry.name)) && - !isSkippedFile(root, fullPath) - ) { - files.push(fullPath) - } - } - - return files -} - -function formatReports(root, reports) { - return reports - .map( - (report) => - `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}` - ) - .join('\n') -} - -export async function main(root = process.cwd()) { - const reports = [] - for (const scanRoot of SCAN_ROOTS) { - const files = await collectSourceFiles(root, path.join(root, scanRoot)) - for (const filePath of files) { - const sourceText = await fs.readFile(filePath, 'utf8') - reports.push(...reportQuadraticBufferConcat(filePath, sourceText)) - } - } - if (reports.length === 0) { - return 0 - } - - console.error('Buffer.concat must not rebuild a loop-carried accumulator.') - console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).') - console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.') - console.error('') - console.error(formatReports(root, reports)) - return 1 -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(await main()) -} diff --git a/config/scripts/check-quadratic-buffer-concat.test.mjs b/config/scripts/check-quadratic-buffer-concat.test.mjs deleted file mode 100644 index bc17ce8c7d3..00000000000 --- a/config/scripts/check-quadratic-buffer-concat.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs' - -describe('check-quadratic-buffer-concat', () => { - it('reports an accumulator rebuilt from itself in a for-of loop', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }' - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('acc') - }) - - it('reports the accumulator when it trails the new chunk', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('reports a spread of the accumulator into its own concat', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('reports the real pre-fix ai-vault stream carry', () => { - const reports = reportQuadraticBufferConcat( - 'session-scanner-parse-cache.ts', - `async function read(stream) { - let remainder = null - for await (const chunk of stream) { - const data = remainder ? Buffer.concat([remainder, chunk]) : chunk - remainder = Buffer.from(data.subarray(lineStart)) - } - }` - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('remainder') - }) - - it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => { - const reports = reportQuadraticBufferConcat( - 'agent-hook-listener.ts', - `function read(fd, size) { - let carryBytes = Buffer.alloc(0) - while (bytesRead < size) { - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - carryBytes = combined.subarray(0, firstNewline) - } - }` - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('carryBytes') - }) - - it('accepts the chunk-list fix that joins once after the loop', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)' - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts spreading a carry chunk list, which is the sanctioned fix', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - `let carryChunks = [] - while (scanEnd > 0) { - const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) - carryChunks = [buffer.subarray(0, firstNewline)] - }` - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts a concat whose result never outlives the iteration', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }' - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }' - ) - - expect(reports).toHaveLength(0) - }) - - it('reports an accumulator declared in a classic for initializer', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('reports a class field accumulator rebuilt inside a loop', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }' - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('this.pending') - }) - - it('reports an accumulator guarded by an emptiness ternary', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('ignores a per-event accumulator with no enclosing loop', () => { - const reports = reportQuadraticBufferConcat( - 'scrcpy-stream-session.ts', - 'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }' - ) - - expect(reports).toHaveLength(0) - }) - - it('ignores files with no Buffer.concat at all', () => { - expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0) - }) -}) diff --git a/config/scripts/check-styled-scrollbars.mjs b/config/scripts/check-styled-scrollbars.mjs deleted file mode 100644 index 12317998e25..00000000000 --- a/config/scripts/check-styled-scrollbars.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import process from 'node:process' - -import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs' -export { - plainClassName, - reportUnstyledScrollbars -} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs' - -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) -const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) - -export function normalizePath(root, filePath) { - return path.relative(root, filePath).split(path.sep).join('/') -} - -function isSkippedFile(root, filePath) { - const relative = normalizePath(root, filePath) - if (relative.includes('.test.') || relative.includes('.spec.')) { - return true - } - return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) -} - -async function collectSourceFiles(root, dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }) - const files = [] - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (!SKIP_PATH_PARTS.has(entry.name)) { - files.push(...(await collectSourceFiles(root, fullPath))) - } - } else if ( - entry.isFile() && - SOURCE_EXTENSIONS.has(path.extname(entry.name)) && - !isSkippedFile(root, fullPath) - ) { - files.push(fullPath) - } - } - - return files -} - -async function collectUnstyledScrollbarReports(root) { - const sourceRoot = path.join(root, 'src', 'renderer', 'src') - const files = await collectSourceFiles(root, sourceRoot) - const reports = [] - - for (const filePath of files) { - const sourceText = await fs.readFile(filePath, 'utf8') - reports.push(...reportUnstyledScrollbars(filePath, sourceText)) - } - - return reports -} - -function formatReports(root, reports) { - return reports - .map( - (report) => - `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}` - ) - .join('\n') -} - -export async function main(root = process.cwd()) { - const reports = await collectUnstyledScrollbarReports(root) - if (reports.length === 0) { - return 0 - } - - console.error('Renderer vertical scroll containers must use an Orca scrollbar style.') - console.error('Put the scrollbar class in the same class literal as the vertical overflow class.') - console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.') - console.error('') - console.error(formatReports(root, reports)) - return 1 -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(await main()) -} diff --git a/config/scripts/check-styled-scrollbars.test.mjs b/config/scripts/check-styled-scrollbars.test.mjs deleted file mode 100644 index c6ff4a72a95..00000000000 --- a/config/scripts/check-styled-scrollbars.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs' - -describe('check-styled-scrollbars', () => { - it('reports renderer vertical scroll containers without an Orca scrollbar style', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts obvious styled vertical scroll containers', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('fails closed when a separate class composer argument supplies the scrollbar style', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts static class composer arguments when the same literal is styled', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('fails closed when a scrollbar class is only conditionally present', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example({ enabled }) { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example({ enabled }) { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('does not require a vertical scrollbar style for horizontal-only overflow', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
 }'
-    )
-
-    expect(reports).toHaveLength(0)
-  })
-
-  it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
-    const reports = reportUnstyledScrollbars(
-      'Example.tsx',
-      'export function Example() { return 
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts matching responsive overflow and scrollbar variants', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts unconditional scrollbar styles for responsive overflow', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('reports inline vertical overflow without an Orca scrollbar class', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts inline vertical overflow with a stable Orca scrollbar class', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => { - for (const classNameExpression of [ - "enabled && 'scrollbar-sleek'", - "enabled ? 'scrollbar-sleek' : undefined", - "enabled || 'scrollbar-sleek'", - "enabled ?? 'scrollbar-sleek'" - ]) { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - `export function Example({ enabled }) { return
}` - ) - - expect(reports, classNameExpression).toHaveLength(1) - } - }) - - it('reports logical inline style spreads without an Orca scrollbar class', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example({ open }) { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('reports JSX spread className props with unstyled vertical overflow', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts JSX spread className props when the same literal is styled', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('uses later spread className props over earlier explicit className props', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('supports variant helper className config', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('normalizes Tailwind variants and important prefixes before matching', () => { - expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto') - expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto') - expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor') - expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor') - }) -}) diff --git a/config/scripts/claude-usage-yield-benchmark.mjs b/config/scripts/claude-usage-yield-benchmark.mjs index c587476268e..aea601c58e5 100644 --- a/config/scripts/claude-usage-yield-benchmark.mjs +++ b/config/scripts/claude-usage-yield-benchmark.mjs @@ -12,11 +12,10 @@ // other work would be a regression, not a win. // // Run with: node config/scripts/claude-usage-yield-benchmark.mjs -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' -import { readdirSync, statSync } from 'node:fs' const REPO_ROOT = new URL('../..', import.meta.url) const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10') diff --git a/config/scripts/cli-runtime-client-deferral-benchmark.mjs b/config/scripts/cli-runtime-client-deferral-benchmark.mjs index 4a7560fc7dd..c322410b253 100644 --- a/config/scripts/cli-runtime-client-deferral-benchmark.mjs +++ b/config/scripts/cli-runtime-client-deferral-benchmark.mjs @@ -196,6 +196,7 @@ try { // Each case is (label, argv, env). The runtime-dependent ones point at an // empty user-data dir so both arms get the same deterministic answer. const isolated = { ORCA_USER_DATA_PATH: userDataPath } + /** @type {Array<[string, string[], Record]>} */ const cases = [ ['orca --help', ['--help'], {}], ['orca help worktree', ['help', 'worktree'], {}], diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 0d4c7dd7180..442b7bd84f7 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') +const { FileMatcher } = require('app-builder-lib/out/fileMatcher') const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') const { createPackagedRuntimeNodeModuleResources, @@ -38,15 +39,39 @@ describe('electron-builder config', () => { '!skill-stubs{,/**/*}', '!resources/skills/**', '!tests{,/**/*}', + '!examples{,/**/*}', '!pr-evidence{,/**/*}', '!Casks{,/**/*}', - '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', + '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}', '!out/**/*.test.js', '!resources/plugins/launch/**' ]) ) }) + // Why: `files` is an all-negation list, so electron-builder's default `**/*` packs + // anything without an explicit `!` entry — examples/ landed without one and shipped + // hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar. + // Drive the real matcher: pinning the pattern string cannot prove it excludes the tree. + it('keeps plugin authoring examples out of app.asar', () => { + const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files) + // copyFiles() prepends this itself once the pattern list is all-negation. + matcher.prependPattern('**/*') + const isPacked = matcher.createFilter() + const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false }) + + for (const authoringOnly of [ + 'examples/plugins/hostile-panel/panel.html', + 'examples/plugins/hostile-panel/orca-plugin.json', + 'examples/plugins/hello-orca/main.mjs', + 'examples/plugins/hello-orca/orca-plugin.json' + ]) { + expect(packs(authoringOnly)).toBe(false) + } + // The negation stays anchored at the app root, so nested `examples` segments still ship. + expect(packs('out/main/examples/index.js')).toBe(true) + }) + it('keeps runtime resources available through extraResources', () => { const bundledPluginResources = expect.objectContaining({ from: 'resources/plugins/launch', diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index cc8db3ca741..eed66def4ed 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { electronViteConfig } from '../../electron.vite.config' const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8') +const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') describe('Electron Vite output contract', () => { it('keeps main-process and plain-Node entries at stable CommonJS paths', () => { @@ -39,4 +40,12 @@ describe('Electron Vite output contract', () => { it('rejects prototype properties as build targets', () => { expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)') }) + + it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => { + expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`') + expect(devRunner).toContain("'Electron Helper.app',") + expect(devRunner).toContain( + "setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)" + ) + }) }) diff --git a/config/scripts/linux-wayland-terminal-exercise.mjs b/config/scripts/linux-wayland-terminal-exercise.mjs index 65ae598db92..4a6b942f984 100644 --- a/config/scripts/linux-wayland-terminal-exercise.mjs +++ b/config/scripts/linux-wayland-terminal-exercise.mjs @@ -200,7 +200,7 @@ export async function setupTerminal(page, repoPath, logPhase) { return pane?.container?.dataset?.ptyId ?? null }) ) - logPhase('setup.pty-bound', `ptyId=${ptyId}`) + logPhase('setup.pty-bound', `ptyId=${String(ptyId)}`) return ptyId } diff --git a/config/scripts/locale-key-override-merge.mjs b/config/scripts/locale-key-override-merge.mjs index 3fb59b6aefc..e72debe7fcd 100644 --- a/config/scripts/locale-key-override-merge.mjs +++ b/config/scripts/locale-key-override-merge.mjs @@ -1,5 +1,6 @@ import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs' import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs' +import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs' export function mergeLocaleKeyOverrides(base) { const merged = { ...base } @@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) { // KO split overrides can share keys with zh/ja repairs; merge per locale. merged[key] = { ...merged[key], ...overrides } } + for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) { + merged[key] = { ...merged[key], ...overrides } + } return merged } diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index aeda4eb6de7..4618f052f1e 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2231,9 +2231,6 @@ "auto.components.settings.DeveloperPermissionsPane.16381e040a": { "ko": "마이크" }, - "auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": { - "ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다." - }, "auto.components.settings.DeveloperPermissionsPane.e119f0d66b": { "ko": "자동화" }, diff --git a/config/scripts/locale-macos-tcc-key-overrides.mjs b/config/scripts/locale-macos-tcc-key-overrides.mjs new file mode 100644 index 00000000000..b7cb8998a0a --- /dev/null +++ b/config/scripts/locale-macos-tcc-key-overrides.mjs @@ -0,0 +1,18 @@ +export const MACOS_TCC_KEY_OVERRIDES = { + 'auto.hooks.useMacosTccPromptNotice.description': { + es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.', + ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。', + ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.', + zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。' + }, + 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': { + es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.', + ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。', + ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.', + zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。' + }, + 'auto.components.settings.DeveloperPermissionsPane.c566bca278': { + ko: '전체 디스크 접근 권한', + zh: '完全磁盘访问权限' + } +} diff --git a/config/scripts/locale-prose-term-exemptions.mjs b/config/scripts/locale-prose-term-exemptions.mjs new file mode 100644 index 00000000000..fbe2d483fa1 --- /dev/null +++ b/config/scripts/locale-prose-term-exemptions.mjs @@ -0,0 +1,19 @@ +const LOCALIZED_PROSE_TERM_KEYS = new Set([ + 'auto.hooks.useMacosTccPromptNotice.description', + 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8' +]) + +const LOCALIZABLE_PROSE_TERMS = new Set([ + 'Agent', + 'Agents', + 'agent', + 'agents', + 'Terminal', + 'Terminals', + 'terminal', + 'terminals' +]) + +export function isLocalizedProseTermContext(term, key) { + return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term) +} diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index c4991ced9d2..a3feb5f59df 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -1,4 +1,5 @@ import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs' +import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs' import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs' import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs' import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs' @@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (isScreenCursorContext(brand, enValue, key)) { continue } + if (isLocalizedProseTermContext(brand, key)) { + continue + } if (includesPreservedLatinTerm(result, brand)) { continue } diff --git a/config/scripts/macos-tcc-prompt-localization.test.mjs b/config/scripts/macos-tcc-prompt-localization.test.mjs new file mode 100644 index 00000000000..3a947765e6e --- /dev/null +++ b/config/scripts/macos-tcc-prompt-localization.test.mjs @@ -0,0 +1,50 @@ +import fs from 'node:fs' + +import { describe, expect, it } from 'vitest' +import { repairTranslatedValue } from './locale-translation-policy.mjs' + +const LOCALES = ['es', 'ja', 'ko', 'zh'] +const KEYS = [ + 'auto.hooks.useMacosTccPromptNotice.title', + 'auto.hooks.useMacosTccPromptNotice.description', + 'auto.hooks.useMacosTccPromptNotice.openSettings', + 'auto.hooks.useMacosTccPromptNotice.dismiss', + 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8', + 'auto.components.settings.DeveloperPermissionsPane.c566bca278' +] + +function readCatalog(locale) { + return JSON.parse( + fs.readFileSync( + new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url), + 'utf8' + ) + ) +} + +function getValue(catalog, key) { + return key.split('.').reduce((value, part) => value[part], catalog) +} + +describe('macOS TCC prompt localization', () => { + it('survives the canonical catalog repair policy', () => { + const english = readCatalog('en') + for (const locale of LOCALES) { + const catalog = readCatalog(locale) + for (const key of KEYS) { + const enValue = getValue(english, key) + const localeValue = getValue(catalog, key) + expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue) + } + } + }) + + it('uses the macOS Full Disk Access labels in Korean and Chinese', () => { + expect( + getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278') + ).toBe('전체 디스크 접근 권한') + expect( + getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278') + ).toBe('完全磁盘访问权限') + }) +}) diff --git a/config/scripts/node-pty-console-list-agent-patch.test.mjs b/config/scripts/node-pty-console-list-agent-patch.test.mjs index c6c8677a6e9..07e97c54832 100644 --- a/config/scripts/node-pty-console-list-agent-patch.test.mjs +++ b/config/scripts/node-pty-console-list-agent-patch.test.mjs @@ -40,7 +40,10 @@ describe('Windows SSH relay node-pty console-list patch', () => { const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource()) patchNodePtyConsoleListAgent(tamperedPatch.root) - writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`) + writeFileSync( + tamperedPatch.agentPath, + `${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift` + ) expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed') }) }) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 74fe975048a..74d14a9a9a1 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -63,6 +63,18 @@ describe('orchestration skill guidance', () => { expect(migration).toContain('task-list --run run_legacy_local') expect(migration).toContain('Read-only inspection never consumes legacy mail') expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain') + expect(migration).toContain('does not cancel the prior assignment') + expect(migration).toContain('invalidate its worktree') + expect(migration).toContain('discard filesystem changes') + expect(migration).toContain('leave it as the only editor in that worktree') + expect(migration).toContain('observe it manually, read-only') + expect(migration).toContain('until it reaches a stable handoff point') + expect(migration).toContain('visible activity is a reason to keep observing') + expect(migration).toContain( + 'Never launch a replacement editor in the same worktree while the legacy worker may still write there.' + ) + expect(migration).toContain('if remaining work needs new lifecycle supervision') + expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`') }) it('treats long-running worker waits as liveness checkpoints, not failures', () => { diff --git a/config/scripts/oxlint-plugin-test-runner.mjs b/config/scripts/oxlint-plugin-test-runner.mjs new file mode 100644 index 00000000000..c5a80e0c106 --- /dev/null +++ b/config/scripts/oxlint-plugin-test-runner.mjs @@ -0,0 +1,58 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' + +const oxlintPackageDirectory = path.dirname( + createRequire(import.meta.url).resolve('oxlint/package.json') +) +const oxlintPath = path.join(oxlintPackageDirectory, 'bin', 'oxlint') + +export function runOxlintPluginOnSource({ + pluginName, + pluginPath, + rules, + source, + extension = 'tsx' +}) { + const directory = mkdtempSync(path.join(tmpdir(), `orca-${pluginName}-lint-`)) + const sourcePath = path.join(directory, `sample.${extension}`) + const configPath = path.join(directory, 'oxlint.json') + + try { + writeFileSync(sourcePath, source) + writeFileSync( + configPath, + JSON.stringify({ + plugins: [], + categories: { + correctness: 'off', + suspicious: 'off', + pedantic: 'off', + perf: 'off', + style: 'off', + restriction: 'off', + nursery: 'off' + }, + jsPlugins: [{ name: pluginName, specifier: pluginPath }], + rules + }) + ) + const result = spawnSync( + process.execPath, + [oxlintPath, '--config', configPath, '--format', 'json', sourcePath], + { encoding: 'utf8' } + ) + if (result.error) { + throw result.error + } + if (!result.stdout.trim()) { + throw new Error(result.stderr || `${pluginName} did not produce Oxlint output`) + } + return JSON.parse(result.stdout).diagnostics + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs new file mode 100644 index 00000000000..bcedb1b5aaa --- /dev/null +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + +const filterStep = prWorkflow.jobs['e2e-paths'].steps.find( + (step) => step.name === 'Filter E2E-relevant paths' +) +const verifyStep = prWorkflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' +) + +describe('PR E2E gate contract', () => { + it('keeps E2E advisory while the suite is red on main', () => { + // Why: pin the deliberate choice so it reads as intentional rather than as + // the "forgot to wire the gate" bug this file originally caught. Gating on a + // suite that fails every scheduled run would block the PRs that fix it. + // Flipping to blocking means updating this expectation too — see the comment + // on verify's Require-successful-checks step for the exact wiring. + expect(prWorkflow.jobs.verify.needs).not.toContain('e2e') + expect(verifyStep.env.E2E).toBeUndefined() + expect(verifyStep.run).not.toContain('$E2E') + }) + + it('runs E2E only when the detector says the PR touches E2E paths', () => { + // Why: without this the job could lose its filter and run on every PR — the + // cost the path filter exists to avoid — while the gate assertions above + // stay green. + expect(prWorkflow.jobs.e2e.needs).toBe('e2e-paths') + expect(prWorkflow.jobs.e2e.if).toBe("needs.e2e-paths.outputs.should_run == 'true'") + expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe( + '${{ steps.filter.outputs.should_run }}' + ) + }) + + it('enforces every job verify depends on', () => { + // Why: derive from verify.needs rather than hardcoding, so adding a required + // job without adding it to the strict loop fails here instead of silently + // leaving that job unenforced. This is what caught GIT_COMPATIBILITY and + // SHELL_CONTRACTS being absent from an earlier hardcoded list. + const strictLoop = verifyStep.run.slice(0, verifyStep.run.indexOf('done')) + for (const job of prWorkflow.jobs.verify.needs) { + const envVar = job.toUpperCase() + expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) + expect(strictLoop).toContain(`"$${envVar}"`) + } + }) + + it('matches the Playwright config where it actually lives', () => { + // Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather + // than inside it. A bare `playwright.` prefix matches no tracked file, so + // editing the runner config would silently skip E2E. + expect(filterStep.run).toContain('tests/playwright\\.') + expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./) + }) + + it('scopes detection to the PR range so base drift cannot false-trigger', () => { + expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"') + expect(filterStep.run).toContain('set -euo pipefail') + }) +}) diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs index a02431afe88..ad66c8cfbf7 100644 --- a/config/scripts/publish-complete-draft-releases.mjs +++ b/config/scripts/publish-complete-draft-releases.mjs @@ -98,7 +98,7 @@ export async function publishCompleteDraftReleases({ for (const release of candidates) { const tag = release.tag_name - if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) { + if (!(await Promise.resolve(isDraftBuiltFromCurrentRef({ tag, release })))) { const reason = 'tag is not built from the current release ref' skipped.push({ tag, reason }) log(`Skipping stale RC draft release ${tag}: ${reason}`) diff --git a/config/scripts/quadratic-buffer-concat-plugin.test.mjs b/config/scripts/quadratic-buffer-concat-plugin.test.mjs new file mode 100644 index 00000000000..68c47a0d661 --- /dev/null +++ b/config/scripts/quadratic-buffer-concat-plugin.test.mjs @@ -0,0 +1,113 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/quadratic-buffer-concat.mjs') + +function lintSource(source) { + return runOxlintPluginOnSource({ + pluginName: 'quadratic-buffer-concat', + pluginPath, + source, + extension: 'ts', + rules: { + 'quadratic-buffer-concat/no-loop-carried-concat': 'warn' + } + }) +} + +const violations = [ + [ + 'self accumulator', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }', + 'acc' + ], + [ + 'trailing accumulator', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }', + 'acc' + ], + [ + 'spread self accumulator', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }', + 'acc' + ], + [ + 'indirect stream carry', + `async function read(stream) { + let remainder = null + for await (const chunk of stream) { + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + remainder = Buffer.from(data.subarray(lineStart)) + } + }`, + 'remainder' + ], + [ + 'indirect transcript carry', + `function read(fd, size) { + let carryBytes = Buffer.alloc(0) + while (bytesRead < size) { + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + carryBytes = combined.subarray(0, firstNewline) + } + }`, + 'carryBytes' + ], + [ + 'classic for initializer', + 'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }', + 'acc' + ], + [ + 'class field accumulator', + 'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }', + 'this.pending' + ], + [ + 'guarded accumulator', + 'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }', + 'acc' + ] +] + +const accepted = [ + [ + 'single concat after loop', + 'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)' + ], + [ + 'spread chunk list', + `let carryChunks = [] + while (scanEnd > 0) { + const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + }` + ], + [ + 'iteration-local result', + 'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }' + ], + [ + 'iteration-local accumulator', + 'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }' + ], + [ + 'no enclosing loop', + 'class S { handle(chunk) { const buffer = Buffer.concat([this.pending, chunk]); this.pending = parse(buffer).pending } }' + ], + ['no Buffer concat', 'export const x = 1'] +] + +describe('quadratic Buffer.concat Oxlint plugin', () => { + it.each(violations)('reports %s', (_name, source, accumulator) => { + const diagnostics = lintSource(source) + + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0].message).toContain(accumulator) + }) + + it.each(accepted)('accepts %s', (_name, source) => { + expect(lintSource(source)).toEqual([]) + }) +}) diff --git a/config/scripts/renderer-scrollbar-style-plugin.test.mjs b/config/scripts/renderer-scrollbar-style-plugin.test.mjs new file mode 100644 index 00000000000..fd67ffbbd84 --- /dev/null +++ b/config/scripts/renderer-scrollbar-style-plugin.test.mjs @@ -0,0 +1,116 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { plainClassName } from '../oxlint-plugins/renderer-scrollbar-style.mjs' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/renderer-scrollbar-style.mjs') + +function lintSource(source) { + return runOxlintPluginOnSource({ + pluginName: 'renderer-scrollbar-style', + pluginPath, + source, + rules: { + 'renderer-scrollbar-style/require-styled-vertical-scrollbar': 'warn' + } + }) +} + +const violations = [ + ['unstyled class', 'export const X = () =>
'], + [ + 'unstyled suffix-important class', + 'export const X = () =>
' + ], + [ + 'unknown scrollbar class', + 'export const X = () =>
' + ], + [ + 'separate class composer arguments', + "export const X = () =>
" + ], + [ + 'conditional scrollbar', + "export const X = ({ enabled }) =>
" + ], + [ + 'arbitrary class wrapper', + "export const X = () =>
" + ], + [ + 'mismatched responsive variants', + 'export const X = () =>
' + ], + ['inline overflow', "export const X = () =>
"], + [ + 'logical inline style spread', + "export const X = ({ open }) =>
" + ], + ['JSX spread class', "export const X = () =>
"], + [ + 'later spread override', + 'export const X = () =>
' + ] +] + +const accepted = [ + [ + 'styled vertical class', + 'export const X = () =>
' + ], + [ + 'styled suffix-important classes', + 'export const X = () =>
' + ], + [ + 'same composer literal', + "export const X = () =>
" + ], + [ + 'same conditional literal', + "export const X = ({ enabled }) =>
" + ], + ['horizontal-only overflow', 'export const X = () =>
'],
+  [
+    'matching responsive variants',
+    'export const X = () => 
' + ], + [ + 'unconditional scrollbar', + 'export const X = () =>
' + ], + [ + 'styled inline overflow', + 'export const X = () =>
' + ], + [ + 'styled JSX spread class', + "export const X = () =>
" + ], + [ + 'variant configuration', + "export const X = () =>
" + ] +] + +describe('renderer scrollbar style Oxlint plugin', () => { + it.each(violations)('reports %s', (_name, source) => { + expect(lintSource(source)).toHaveLength(1) + }) + + it.each(accepted)('accepts %s', (_name, source) => { + expect(lintSource(source)).toEqual([]) + }) + + it.each([ + ['md:overflow-y-auto', 'overflow-y-auto'], + ['[&:hover]:overflow-y-auto', 'overflow-y-auto'], + ['md:!scrollbar-editor', 'scrollbar-editor'], + ['!scrollbar-editor', 'scrollbar-editor'], + ['overflow-y-auto!', 'overflow-y-auto'], + ['md:scrollbar-editor!', 'scrollbar-editor'] + ])('normalizes %s', (token, expected) => { + expect(plainClassName(token)).toBe(expected) + }) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 0a578140593..eb4addedd5a 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -128,10 +128,8 @@ function prepareMacDevElectronApp() { const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev' const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot - // v6: bundle the notification-status helper (real permission readout) and - // ad-hoc re-sign after plist edits so Notification Center accepts the - // bundle; bumping forces stale cached copies to be recreated. - const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6' + // v7: give the terminal daemon helper an Orca-specific TCC identity. + const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7' const hash = createHash('sha1') .update( `${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}` @@ -155,6 +153,7 @@ function prepareMacDevElectronApp() { // Electron drops clicks for notification ids it didn't create, so the // click is lost, not misdirected. const bundleId = 'com.stablyai.orca.dev' + const helperBundleId = `${bundleId}.helper` process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId const expectedMarker = JSON.stringify( { title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion }, @@ -205,9 +204,18 @@ function prepareMacDevElectronApp() { restoreElectronFrameworkSymlinks(appPath) const plistPath = path.join(appPath, 'Contents', 'Info.plist') + const helperPlistPath = path.join( + appPath, + 'Contents', + 'Frameworks', + 'Electron Helper.app', + 'Contents', + 'Info.plist' + ) setPlistValue(plistPath, 'CFBundleName', title) setPlistValue(plistPath, 'CFBundleDisplayName', title) setPlistValue(plistPath, 'CFBundleIdentifier', bundleId) + setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId) // Why: the notification-status helper reads the app's real macOS // notification authorization (UNUserNotificationCenter has no Electron diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index 2e5112b011d..baa061b3590 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -572,7 +572,7 @@ async function main() { if (options.output) { mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }) writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`) - console.log(`[idle-cpu] wrote ${options.output}`) + console.log(`[idle-cpu] wrote ${String(options.output)}`) } console.log( JSON.stringify( diff --git a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs b/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs deleted file mode 100644 index 2a9d13b6fee..00000000000 --- a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs +++ /dev/null @@ -1,312 +0,0 @@ -// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. -import ts from 'typescript-api' - -const STYLED_SCROLLBAR_CLASSES = new Set( - 'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ') -) -// Why: vertical scroll is where native scrollbar drift keeps recurring. The -// guard intentionally ignores horizontal-only overflow. -const VERTICAL_SCROLL_CLASSES = new Set( - 'overflow-auto overflow-scroll overflow-y-auto overflow-y-scroll'.split(' ') -) -const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll']) - -export function plainClassName(token) { - const normalizedToken = token.startsWith('!') ? token.slice(1) : token - const parts = [] - let bracketDepth = 0 - let currentPart = '' - - for (const char of normalizedToken) { - if (char === '[') { - bracketDepth += 1 - } else if (char === ']') { - bracketDepth = Math.max(0, bracketDepth - 1) - } - - if (char === ':' && bracketDepth === 0) { - parts.push(currentPart) - currentPart = '' - continue - } - currentPart += char - } - - parts.push(currentPart) - const className = parts.at(-1) ?? '' - return className.startsWith('!') ? className.slice(1) : className -} - -function classTokenParts(token) { - const variants = [] - let bracketDepth = 0 - let currentPart = '' - - for (const char of token.startsWith('!') ? token.slice(1) : token) { - if (char === '[') { - bracketDepth += 1 - } else if (char === ']') { - bracketDepth = Math.max(0, bracketDepth - 1) - } - if (char === ':' && bracketDepth === 0) { - variants.push(currentPart) - currentPart = '' - continue - } - currentPart += char - } - - return { className: plainClassName(token), variants: variants.filter(Boolean) } -} - -function classTokens(text) { - return text.split(/\s+/).filter(Boolean).map(classTokenParts) -} - -function sameVariants(left, right) { - return left.length === right.length && left.every((variant, index) => variant === right[index]) -} - -function literalHasScrollbarForVertical(text, verticalToken) { - return classTokens(text).some((candidate) => { - if (!STYLED_SCROLLBAR_CLASSES.has(candidate.className)) { - return false - } - return ( - candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants) - ) - }) -} - -function uncoveredVerticalClass(text) { - return classTokens(text).find((token) => { - return ( - VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token) - ) - }) -} - -function reportAt(node, filePath, sourceFile, text) { - const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) - return { - filePath, - line: position.line + 1, - column: position.character + 1, - text - } -} - -function stringLiteralTexts(node) { - if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - return [node.text] - } - if (!ts.isTemplateExpression(node)) { - return [] - } - return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)] -} - -function collectClassLiteralReports(node, filePath, sourceFile) { - const reports = [] - - function visit(current) { - for (const text of stringLiteralTexts(current)) { - const uncovered = uncoveredVerticalClass(text) - if (uncovered) { - reports.push(reportAt(current, filePath, sourceFile, uncovered.className)) - } - } - - ts.forEachChild(current, visit) - } - - visit(node) - return reports -} - -function expressionHasStyledScrollbarLiteral(node) { - let hasStyledScrollbar = false - - function visit(current) { - if (hasStyledScrollbar) { - return - } - if ( - stringLiteralTexts(current).some((text) => - classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className)) - ) - ) { - hasStyledScrollbar = true - return - } - // Why: a scrollbar literal that only renders on some branches must not be - // treated as covering an unconditional inline overflow. Skip conditional - // and short-circuit expressions when proving unconditional coverage. - if (ts.isConditionalExpression(current)) { - return - } - if ( - ts.isBinaryExpression(current) && - (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || - current.operatorToken.kind === ts.SyntaxKind.BarBarToken || - current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) - ) { - return - } - ts.forEachChild(current, visit) - } - - visit(node) - return hasStyledScrollbar -} - -function propertyNameText(name) { - if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { - return name.text - } - if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) { - return name.expression.text - } - return undefined -} - -function styleValueIsVerticalScroll(propertyName, value) { - const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean) - if (parts.length === 0) { - return false - } - if (propertyName === 'overflowY' || propertyName === 'overflow-y') { - return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0]) - } - if (propertyName !== 'overflow') { - return false - } - const verticalValue = parts.length > 1 ? parts[1] : parts[0] - return VERTICAL_SCROLL_STYLE_VALUES.has(verticalValue) -} - -function collectStyleReports(node, filePath, sourceFile) { - const reports = [] - - function visit(current) { - if (ts.isPropertyAssignment(current)) { - const propertyName = propertyNameText(current.name) - for (const value of propertyName ? stringLiteralTexts(current.initializer) : []) { - if (styleValueIsVerticalScroll(propertyName, value)) { - reports.push(reportAt(current, filePath, sourceFile, 'inline vertical scroll')) - } - } - ts.forEachChild(current.initializer, visit) - return - } - ts.forEachChild(current, visit) - } - - visit(node) - return reports -} - -function jsxAttributeName(attribute) { - return ts.isIdentifier(attribute.name) ? attribute.name.text : undefined -} - -function jsxAttributeExpression(attribute) { - if (ts.isStringLiteral(attribute.initializer)) { - return attribute.initializer - } - if (attribute.initializer && ts.isJsxExpression(attribute.initializer)) { - return attribute.initializer.expression - } - return undefined -} - -function spreadPropExpressions(node, propName) { - if ( - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) || - ts.isSatisfiesExpression(node) - ) { - return spreadPropExpressions(node.expression, propName) - } - if (ts.isConditionalExpression(node)) { - return [ - ...spreadPropExpressions(node.whenTrue, propName), - ...spreadPropExpressions(node.whenFalse, propName) - ] - } - if (ts.isBinaryExpression(node)) { - return [ - ...spreadPropExpressions(node.left, propName), - ...spreadPropExpressions(node.right, propName) - ] - } - if (!ts.isObjectLiteralExpression(node)) { - return [] - } - return node.properties.flatMap((property) => { - if (ts.isSpreadAssignment(property)) { - return spreadPropExpressions(property.expression, propName) - } - if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propName) { - return [property.initializer] - } - return [] - }) -} - -function jsxElementReports(node, filePath, sourceFile) { - const reports = [] - let classExpression - const styleExpressions = [] - - for (const attribute of node.attributes.properties) { - if (ts.isJsxSpreadAttribute(attribute)) { - // Why: at runtime React applies attributes in source order, so a later - // spread that supplies className overrides an earlier explicit className. - const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1) - if (spreadClassExpression) { - classExpression = spreadClassExpression - } - styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style')) - } else if (jsxAttributeName(attribute) === 'className') { - classExpression = jsxAttributeExpression(attribute) - } else if (jsxAttributeName(attribute) === 'style') { - const expression = jsxAttributeExpression(attribute) - if (expression) { - styleExpressions.push(expression) - } - } - } - - if (classExpression) { - reports.push(...collectClassLiteralReports(classExpression, filePath, sourceFile)) - } - if (classExpression && expressionHasStyledScrollbarLiteral(classExpression)) { - return reports - } - for (const expression of styleExpressions) { - reports.push(...collectStyleReports(expression, filePath, sourceFile)) - } - return reports -} - -export function reportUnstyledScrollbars(filePath, sourceText) { - const sourceFile = ts.createSourceFile( - filePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ) - const reports = [] - - function visit(node) { - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - reports.push(...jsxElementReports(node, filePath, sourceFile)) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return reports -} diff --git a/config/scripts/terminal-control-strip-benchmark.mjs b/config/scripts/terminal-control-strip-benchmark.mjs index c06eb2a7ff0..52c563050cf 100644 --- a/config/scripts/terminal-control-strip-benchmark.mjs +++ b/config/scripts/terminal-control-strip-benchmark.mjs @@ -1,6 +1,54 @@ #!/usr/bin/env node -// Mirrors the complete pre/post stripTerminalControl paths at their production size caps. +// Compares legacy, slice-run, and production-adaptive control stripping. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import nodeModule from 'node:module' import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const PRODUCTION_SOURCE = readFileSync( + new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url), + 'utf8' +) +for (const marker of [ + 'export function stripTerminalControl(data: string): string', + 'strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT', + 'output += withoutAnsi.slice(runStart, index)', + // Retuning either density constant changes which fixtures sit above/below the trigger, so + // pin the literals rather than the names: a silent retune would leave the sub-threshold + // fixture measuring a boundary that no longer exists. + 'const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64', + 'const CONTROL_DENSITY_FALLBACK_COUNT = 32' +]) { + if (!PRODUCTION_SOURCE.includes(marker)) { + throw new Error(`terminal-control-stripping.ts no longer contains \`${marker}\``) + } +} + +const { stripTerminalControl: stripAdaptive } = await import( + new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url).href +) const ESC = String.fromCharCode(0x1b) const BEL = String.fromCharCode(0x07) @@ -15,6 +63,9 @@ const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( const HISTORY_LIMIT = 300 const SCAN_LIMIT = 4096 const SAMPLE_ID_LENGTH = 24 +// Mirrors terminal-control-stripping.ts; the marker guard above fails if either is retuned. +const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64 +const CONTROL_DENSITY_FALLBACK_COUNT = 32 const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501') let resultChecksum = 0 let validatedPairs = 0 @@ -75,6 +126,25 @@ function stripSliceRuns(data) { return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) } +function adaptiveFallbackIndex(data) { + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + let strippedInBlock = 0 + let blockEnd = 64 + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (index === blockEnd) { + strippedInBlock = 0 + blockEnd += 64 + } + if (isStrippedCode(withoutAnsi.charCodeAt(index))) { + strippedInBlock += 1 + if (strippedInBlock === 32) { + return index + } + } + } + return -1 +} + function fixedSampleId(sampleId) { return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH) } @@ -95,6 +165,26 @@ function makeTuiFixture(length, sampleId, strippedControl) { return text + 'x'.repeat(length - text.length) } +// 31 controls per 64-unit block: one below the fallback trigger, so the adaptive path keeps +// slice-run bookkeeping on a shape dense enough to lose to the per-character legacy. This is the +// worst surviving case; it exists so the narrowed adverse window stays visible instead of hiding +// behind the 50% fixture, where the fallback fires and wins. +function makeSubThresholdDenseFixture(length, sampleId, strippedControl) { + const id = fixedSampleId(sampleId) + const units = [] + for (let index = 0; index < length; index += 1) { + const blockOffset = index % CONTROL_DENSITY_BLOCK_CODE_UNITS + if (index < id.length) { + units.push(id[index]) + } else if (blockOffset % 2 === 1 && blockOffset < (CONTROL_DENSITY_FALLBACK_COUNT - 1) * 2) { + units.push(strippedControl) + } else { + units.push(String.fromCharCode(97 + (index % 26))) + } + } + return units.join('') +} + function makeDenseFixture(length, sampleId, strippedControl) { const prefix = `\x1b[35m${fixedSampleId(sampleId)}` const suffix = '\x1b[0m' @@ -120,33 +210,35 @@ function consumeOutput(output) { resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2)) } -function recordPair(fixture, sampleId, perCharFirst, samples) { - const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02') - const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01') - if ( - perCharFixture === sliceRunsFixture || - perCharFixture.length !== fixture.length || - sliceRunsFixture.length !== fixture.length - ) { +const IMPLEMENTATIONS = [ + ['perChar', stripPerChar, '\x01'], + ['sliceRuns', stripSliceRuns, '\x02'], + ['adaptive', stripAdaptive, '\x03'] +] + +function recordRotation(fixture, sampleId, lead, samples) { + const inputs = IMPLEMENTATIONS.map(([name, strip, control]) => ({ + name, + strip, + input: fixture.make(sampleId, control) + })) + if (inputs.some(({ input }) => input.length !== fixture.length)) { throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`) } - let perCharResult - let sliceRunsResult - if (perCharFirst) { - perCharResult = measure(stripPerChar, perCharFixture) - sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) - } else { - sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) - perCharResult = measure(stripPerChar, perCharFixture) + const results = new Map() + for (let offset = 0; offset < inputs.length; offset += 1) { + const entry = inputs[(lead + offset) % inputs.length] + results.set(entry.name, measure(entry.strip, entry.input)) } - if (perCharResult.output !== sliceRunsResult.output) { + const outputs = [...results.values()].map(({ output }) => output) + if (new Set(outputs).size !== 1) { throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`) } - consumeOutput(perCharResult.output) - consumeOutput(sliceRunsResult.output) + for (const [name, result] of results) { + consumeOutput(result.output) + samples[name].push(result.elapsed) + } validatedPairs += 1 - samples.perChar.push(perCharResult.elapsed) - samples.sliceRuns.push(sliceRunsResult.elapsed) } const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length @@ -171,32 +263,68 @@ const fixtures = [ label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`, length: SCAN_LIMIT, make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control) + }, + { + label: `${SCAN_LIMIT} scan 31/block C0`, + length: SCAN_LIMIT, + make: (sampleId, control) => makeSubThresholdDenseFixture(SCAN_LIMIT, sampleId, control) } ] +const selectorFixtures = [ + { label: '31 controls', data: `${'\x01'.repeat(31)}${'a'.repeat(33)}`, expected: -1 }, + { label: '32 controls', data: `${'\x01'.repeat(32)}${'a'.repeat(32)}`, expected: 31 }, + { + label: 'block reset at 64', + data: `${'\x01'.repeat(31)}${'a'.repeat(33)}${'\x01'.repeat(32)}`, + expected: 95 + }, + { + label: 'late dense block', + data: `${'a'.repeat(64 * 3)}${'\x01'.repeat(32)}tail`, + expected: 223 + }, + { + label: 'routine TUI', + data: makeTuiFixture(SCAN_LIMIT, 'selector', '\x01'), + expected: -1 + }, + { + label: '31/block never triggers', + data: makeSubThresholdDenseFixture(SCAN_LIMIT, 'selector', '\x01'), + expected: -1 + } +] +for (const fixture of selectorFixtures) { + const actual = adaptiveFallbackIndex(fixture.data) + if (actual !== fixture.expected) { + throw new Error(`${fixture.label} fallback index ${actual}, expected ${fixture.expected}`) + } +} + const pad = (value, width) => String(value).padStart(width) console.log('Complete stripTerminalControl path. Lower is better.') +console.log(`iterations=${ITERATIONS} (${ITERATIONS * 3} rotated samples/implementation, median)`) console.log( - `iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)` -) -console.log( - `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}` + `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('adaptive', 11)} ${pad('vs legacy', 10)} ${pad('vs slice', 9)}` ) for (const fixture of fixtures) { - const samples = { perChar: [], sliceRuns: [] } + const samples = { perChar: [], sliceRuns: [], adaptive: [] } for (let index = 0; index < ITERATIONS; index += 1) { - const orders = index % 2 === 0 ? [true, false] : [false, true] - for (const perCharFirst of orders) { - const orderLabel = perCharFirst ? 'per-first' : 'slice-first' - recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples) + for (let lead = 0; lead < IMPLEMENTATIONS.length; lead += 1) { + recordRotation(fixture, `${index}:lead-${lead}`, lead, samples) } } const perChar = median(samples.perChar) const sliceRuns = median(samples.sliceRuns) + const adaptive = median(samples.adaptive) console.log( - `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}` + `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(adaptive * 1000).toFixed(1)} us`, 11)} ${pad(`${(perChar / adaptive).toFixed(2)}x`, 10)} ${pad(`${(sliceRuns / adaptive).toFixed(2)}x`, 9)}` ) } -console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`) +console.log( + `\nvalidated=${validatedPairs} measured rotations, result checksum=${resultChecksum >>> 0}` +) +console.log(`selector checks=${selectorFixtures.length}`) console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.') diff --git a/config/scripts/terminal-output-frame-chunk-benchmark.mjs b/config/scripts/terminal-output-frame-chunk-benchmark.mjs index 6dd6364524c..c18d8eb2032 100644 --- a/config/scripts/terminal-output-frame-chunk-benchmark.mjs +++ b/config/scripts/terminal-output-frame-chunk-benchmark.mjs @@ -8,10 +8,8 @@ // `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the // same per-code-point walk over the whole payload a second time. // -// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point -// string, chunk text taken as data.slice(chunkStart, end), and a gate of -// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a -// string's UTF-8 length is never below its UTF-16 length. +// The fix: one charCodeAt scan computing UTF-8 width inline, slices for chunk text, +// and bounded byte probes when UTF-16 length alone cannot prove fit or overflow. // // BOTH arms run the complete production path, encodeTerminalStreamText included, and // their emitted frames (base64 + seq + opcode) are compared before any timing, so an @@ -48,11 +46,13 @@ nodeModule.registerHooks({ }) const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40') +const GATE_ITERATIONS = Number(process.env.ORCA_FRAME_GATE_BENCH_ITERATIONS ?? '2000') const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8') const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6') for (const [name, value] of [ ['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_FRAME_GATE_BENCH_ITERATIONS', GATE_ITERATIONS], ['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP], ['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS] ]) { @@ -73,12 +73,19 @@ const CLIPBOARD_SOURCE = readFileSync( 'utf8' ) -// Why re-read the sources: this benchmark's claim is that the OLD arm paid a -// measureClipboardTextByteLength call per code point and the NEW one pays a single -// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming -// the function would satisfy a substring check. +// Match executable source markers so a stale benchmark fails instead of misleading. for (const [source, label, marker] of [ - [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"], + [ + CHUNK_SOURCE, + 'terminal-output-frame-chunks.ts', + 'export function exceedsTerminalStreamChunkBytes(data: string): boolean' + ], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS'], + [ + CHUNK_SOURCE, + 'terminal-output-frame-chunks.ts', + 'terminalStreamByteLength(data.slice(start, end))' + ], [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'], [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'], [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('], @@ -103,10 +110,17 @@ const { measureClipboardTextByteLength } = await import( const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import( new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href ) -const { iterateTerminalOutputFrameChunks } = await import( +const { exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks } = await import( new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href ) +function previousGate(data) { + return ( + data.length > TERMINAL_STREAM_CHUNK_BYTES || + Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES + ) +} + // Pre-fix arm: the exact code that shipped, including the second full walk in the gate. function* iterateBefore(data, meta) { const rawLength = meta?.rawLength ?? data.length @@ -260,6 +274,11 @@ const fixtures = [ data: repeatTo(LONE_HIGH, 64 * 1024), meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) }, + { + label: 'late-wide gate miss (3 chunks)', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`, + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, { label: 'ascii 512KiB (snapshot chunking)', data: 'x'.repeat(512 * 1024), @@ -309,6 +328,38 @@ function measureInterleaved(data, meta) { return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) } } +let gateChecksum = 0 + +function drainGate(gate, data) { + gateChecksum = Math.imul(gateChecksum ^ (gate(data) ? 1 : 0), 16777619) >>> 0 +} + +function measureGateInterleaved(data) { + for (let index = 0; index < WARMUP * 10; index += 1) { + drainGate(previousGate, data) + drainGate(exceedsTerminalStreamChunkBytes, data) + } + const previousSamples = [] + const boundedSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + const run = (gate, samples) => { + const start = performance.now() + for (let index = 0; index < GATE_ITERATIONS; index += 1) { + drainGate(gate, data) + } + samples.push((performance.now() - start) / GATE_ITERATIONS) + } + if (round % 2 === 0) { + run(previousGate, previousSamples) + run(exceedsTerminalStreamChunkBytes, boundedSamples) + } else { + run(exceedsTerminalStreamChunkBytes, boundedSamples) + run(previousGate, previousSamples) + } + } + return { previousMs: median(previousSamples), boundedMs: median(boundedSamples) } +} + const pad = (value, width) => String(value).padStart(width) console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.') console.log( @@ -342,6 +393,39 @@ for (const fixture of fixtures) { console.log( `\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}` ) +const gateFixtures = [ + { label: 'ascii 4KiB fit proof', data: 'x'.repeat(4 * 1024) }, + { + label: 'three-byte exact-cap fit proof', + data: '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3) + }, + { + label: 'late-wide fit after probes', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(11_000)}` + }, + { + label: 'late-wide miss during probes', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}` + }, + { label: 'ascii 64KiB overflow proof', data: 'x'.repeat(64 * 1024) } +] + +console.log('\nTerminal frame-fit gate only. Lower is better.') +console.log( + `${pad('fixture', 34)} ${pad('result', 8)} ${pad('whole scan', 12)} ${pad('bounded', 11)} ${pad('speedup', 9)}` +) +for (const fixture of gateFixtures) { + const previous = previousGate(fixture.data) + const bounded = exceedsTerminalStreamChunkBytes(fixture.data) + if (previous !== bounded) { + throw new Error(`gate result differs for ${fixture.label}`) + } + const { previousMs, boundedMs } = measureGateInterleaved(fixture.data) + console.log( + `${pad(fixture.label, 34)} ${pad(bounded ? 'miss' : 'fit', 8)} ${pad(`${(previousMs * 1000).toFixed(2)} us`, 12)} ${pad(`${(boundedMs * 1000).toFixed(2)} us`, 11)} ${pad(boundedMs > 0 ? `${(previousMs / boundedMs).toFixed(2)}x` : 'n/a', 9)}` + ) +} +console.log(`gate fixtures=${gateFixtures.length}, checksum=${gateChecksum >>> 0}`) console.log( 'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.' ) diff --git a/docs/assets/wechat-qr-group6.jpg b/docs/assets/wechat-qr-group6.jpg new file mode 100644 index 00000000000..6032ba2f8a3 Binary files /dev/null and b/docs/assets/wechat-qr-group6.jpg differ diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index cd4b32b6f6a..ddbf633b321 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -243,9 +243,10 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr - **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces. -- **WeChat :** Les groupes 1 et 2 sont complets — vous pouvez rejoindre le troisième. +- **WeChat :** Si le groupe 5 est complet, vous pouvez rejoindre le groupe 6. - QR code WeChat de la communauté Orca + QR code WeChat groupe 5 de la communauté Orca + QR code WeChat groupe 6 de la communauté Orca - **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues). - **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index b4eba0b2ccb..0a68d205665 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,9 +235,10 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 其他群已满,现在加入第 5 群。 +- **微信:** 如果第 5 群已满,可加入第 6 群。 - Orca 社区微信群二维码 + Orca 社区微信第 5 群二维码 + Orca 社区微信第 6 群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 diff --git a/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md b/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md new file mode 100644 index 00000000000..b3453c0c1d2 --- /dev/null +++ b/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md @@ -0,0 +1,1009 @@ +# Direct SSH reconnect fan-out + +Status: implemented and validated; ready for maintainer merge + +This design was reviewed in two full rounds, reconciled with current main, and implemented across main, preload, renderer, shared contracts, reliability gates, and Docker SSH fixtures. Review findings are absorbed into the invariants and implementation below. + +Validated against `origin/main` at `21dee21a6d9d398bb332ddf0f85fbb4d5de7cd1b`. Current main includes remote-runtime resume/online recovery (#8255), worktree-owned multi-host routing (#10986), negotiated paired close intent (#10129), fail-closed runtime SSH setup (#10799), terminal-view parking (#11016), host-correct SSH folder adoption (#10818), hydration-loop ID indexes (#10891), runtime output chunking (#10915), consolidated changed-code quality gates (#11117), orchestration migration safety (#11107), and CLI-compatible remote timeout parsing (#11206). None replaces the direct desktop SSH reconnect path, but each constrains its ownership and lifecycle integration below. + +Scope: direct SSH reconnect recovery across main, preload, and renderer + +## Summary + +One direct SSH reconnect currently starts two sequential target-preparation waves, or three when a nonempty remote-workspace snapshot is applied. Each wave refreshes every target repo and then lineage. Because the next preparation starts after the previous one settles, the existing in-flight repo coalescer does not collapse these sequential scans. Simultaneous targets multiply the work, while disconnect handling separately calls the single-tab `clearTabPtyId` action once per live tab. + +Replace this with host-qualified, epoch-fenced provider reads and a renderer coordinator with two distinct modes: + +- reconnect finalization retries the exact target's terminal panes immediately, before optional discovery; +- preparation-only refreshes the exact target's catalog, worktrees, and lineage without remounting panes. + +Main owns one provider-incarnation authority per direct SSH target. It is a strict composition of the existing `connectionGeneration` and a new opaque `providerEpoch`: one helper rotates both atomically on the same transition set, and provider work carries and compares the pair. Existing file-mutation fencing continues to carry `connectionGeneration`, but it now advances on every provider-invalidating transition too; reconnect recovery cannot advance one clock without revoking the other. Every authoritative request carries the exact execution host and captured authority through preload to main; main selects the provider by that complete identity and revalidates the authority before any durable side effect. Renderer preparation deduplicates only overlapping work with identical concrete inputs. A completed preparation is not cached for the lifetime of an authority. + +Direct SSH provider calls use a dedicated fair limiter with five local slots, explicit deadlines, and cancellation. Cancel acknowledgement means local waiter settlement; the existing fire-and-forget `rpc.cancel` does not acknowledge relay-handler completion. A separate cancel-debt allowance bounds replacement admission while relay work may be finishing. The five-slot bound applies only to locally unsettled detected-worktree provider calls submitted by this coordinator; aggregate telemetry reports the late-work allowance and other same-relay traffic separately. Runtime discovery, sidebar refreshes, filesystem events, catalog reads, and lineage reads retain their own concurrency behavior and are measured separately. + +This remains separate from the sidebar fix in `9d3ae3adc7`, which does not own `ssh:state-changed`, remote-workspace preparation, or terminal binding cleanup. + +## Goals + +- Recover terminal panes without waiting for Git discovery on the same or another target. +- Keep coordinator-owned direct SSH provider work bounded and fair across targets. +- Make provider selection correct when repo IDs collide across local, direct SSH, and remote-runtime hosts. +- Reject obsolete provider results before any main or renderer authoritative mutation. +- Preserve final-state convergence for later same-connection remote snapshots and wake refreshes. +- Keep disconnect and retry scope symmetric across Git worktrees and folder workspaces. +- Preserve relay reattach identifiers and terminal/session recovery semantics. +- Make timeout, cancellation, non-authoritative data, and operational failure separately observable without exposing identifiers. + +## Non-goals + +- Changing SSH credentials, backoff policy, or user-facing reconnect controls. +- Moving runtime-owned work into the direct SSH coordinator. +- Changing the component-scoped sidebar refresh queue added by `9d3ae3adc7`; the shared detected-worktree provider coalescer does gain lease accounting. +- Expanding remote-workspace serialization to folder workspaces. +- Changing Git commands, worktree parsing, or Git capability detection. +- Changing the remote-runtime wire protocol. +- Establishing a renderer-wide or application-wide provider-call ceiling. + +## Current flow and root cause + +### Connected path + +`applySshConnectionStateChange` currently: + +1. filters `store.repos` by raw `connectionId`; +2. calls `Promise.all(remoteRepos.map(fetchWorktrees))`; +3. calls `fetchWorktreeLineage`; +4. scans target worktrees and bumps terminal generations one worktree at a time; and +5. calls `syncRemoteWorkspaceAfterConnect`. + +`syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, repeating repo and lineage refresh. A nonempty snapshot then reaches `applyRemoteWorkspaceSnapshot`, which prepares a third time. + +For a target with `R` repos, the connected path therefore performs `2R` detected-worktree scans and two lineage reads without a snapshot, or `3R` scans and three lineage reads with one. The existing detected-worktree single-flight joins only requests whose provider reads overlap. These preparation waves are sequential in the current call chain, so it does not collapse their scan count. Other overlapping callers can still join a scan; telemetry must measure observed calls rather than infer a global `kR` multiplier. + +### Disconnect path + +Terminal failure states walk repo-derived worktrees and call `clearTabPtyId(tab.id)` for each tab whose `ptyId` is present. The general single-tab action scans workspace buckets, clones global terminal maps, publishes store changes, bumps worktree activity, and can persist metadata on every call. + +For `T` live tabs, `W` workspace buckets, and terminal maps of size `M`, synchronous work is approximately `O(T × (W + M))`, plus repeated Zustand publications and session-persistence debounce resets. Connection loss is not user activity. + +### Boundary and ownership defects + +- Renderer `fetchWorktrees(repoId, { executionHostId })` uses the host to choose and stamp renderer ownership, but the local preload/main request currently carries only `repoId`. +- Main then calls first-match `store.getRepo(repoId)` and cannot enforce the renderer's intended host. The proven producer is renderer-catalog aliasing across hosts, not duplicate UUID rows created by main; same-ID main rows remain a defensive ambiguity case. +- Main can prune lineage and backfill metadata before renderer receives a result, so a renderer-only stale-result fence is insufficient. +- Current main-owned `connectionGeneration` and the renderer-local state-change counter have different sources and advance rules. Only the main-owned value is authoritative. +- Relay replacement can change the provider incarnation without advancing the existing main-owned generation. The correction is to rotate the generation and provider epoch together, not to use the renderer counter. +- Raw repo-ID filtering can select another execution host. Folder workspace keys are omitted entirely. +- Direct SSH catalog and lineage preparation follows focused-runtime ownership in some paths. +- Direct SSH lineage preparation currently calls bare `fetchWorktreeLineage()`, whose ownership can follow focused-runtime settings rather than the SSH target. + +### Deterministic current-main baseline + +The falsifiable current-main invariants are: + +1. one connected direct SSH event must issue at most one detected-worktree scan per exact repo input and one host-qualified lineage read; +2. an authoritative detected-worktree or lineage result must identify one exact execution host and, for direct SSH, the complete provider authority; +3. explicit worktree, repo-derived, runtime-owner, folder, and PTY provenance must not contradict one another; and +4. a first timeout classified as retryable must not release lineage, sync, or token creation before its retry reaches a terminal outcome. + +Current main violates the first two at the smallest deterministic seams: + +- `applySshConnectionStateChange` runs `fetchWorktrees` plus lineage and then calls `syncRemoteWorkspaceAfterConnect`; +- `syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, and a nonempty snapshot calls `applyRemoteWorkspaceSnapshot`, which calls it again; +- the shared renderer coalescer joins only overlapping promises and deletes the entry when they settle, so these awaited sequential waves remain `2R` scans and two lineage reads without a snapshot or `3R` and three with one; +- renderer refresh selection accepts `executionHostId`, while preload `worktrees.listDetected` and main `worktrees:listDetected` accept only `{ repoId }`; main then uses first-match `store.getRepo(repoId)`. + +Baseline commands run from the rebased worktree: + +```bash +pnpm install --frozen-lockfile +rg -n "prepareRemoteWorkspaceTarget|applyRemoteWorkspaceSnapshot|syncRemoteWorkspaceAfterConnect|store.fetchWorktrees|fetchWorktreeLineage" src/renderer/src/hooks/useIpcEvents.ts +rg -n "listDetected: \\(args: \\{ repoId: string \\}\\)|store.getRepo\\(args.repoId\\)" src/preload/api-types.ts src/main/ipc/worktrees.ts +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktrees.test.ts -t "coalesces concurrent duplicate refreshes for the same repo and host|keeps same-repo refreshes separate for different execution hosts|fetches the requested host when duplicate repo ids exist" +pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees.test.ts -t "coalesces concurrent authoritative detected worktree scans" +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts -t "clears stale remote PTYs when an SSH connection fully disconnects|waits for the remote workspace client id before dropping self notifications" +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts +pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts -t "surfaces relay channel loss while the SSH connection remains alive|does not broadcast a premature connected when relay deploy fails|times out after 30s with no response|advances both recovery schedulers" +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts src/renderer/src/lib/manual-terminal-worktree-parking.test.ts +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/agent-background-session-launch-host.test.ts src/renderer/src/lib/launch-agent-background-session-remote.test.ts src/renderer/src/hooks/useAutomationDispatchEvents.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts +pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktree-by-id-index.test.ts src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +``` + +Results initially on `79ec57d04`, then rerun after rebasing through `974447175`: 3/3, 1/1, 2/2, 4/4, and 3/3 selected SSH seam tests passed respectively; the three newly relevant parking files passed 39/39. On `1fd0f731f`, the four folder-automation/adoption files passed 516/516. On `694363805`, the hydration-index and output-frame equivalence files passed 25/25. Source-contract inspection confirmed the `2R`/`3R` call graph and the dropped host field; later main changes did not alter those seams. This is the historical baseline evidence used to define the candidate oracles. Candidate runtime and Docker SSH evidence is recorded separately below. + +### Candidate implementation and validation + +The candidate implements the composed authority pair, host-qualified catalog/worktree/lineage reads, separate waiter and provider identities, five-slot fair scheduling with a seven-start provider budget, the first-timeout retry barrier, exact-target terminal recovery, fenced remote-workspace hydration, and privacy-safe aggregate telemetry. Coordinator routing defaults on and can be disabled per build with `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or per renderer session with `orca.directSshReconnectCoordinator.enabled=false`; the fallback retains host/authority fencing, atomic terminal recovery, and bounded preparation. + +Deterministic validation on the final rebased implementation passed all 42 changed unit suites (1,960 tests), including the 11-file direct-SSH terminal gate (637 tests), the 11-file renderer provider/transport gate (763 tests), and the 12-file main/preload/runtime/shared authority gate (513 tests), plus full typecheck, changed-code quality, reliability-gate and max-lines checks, `git diff --check`, and an Electron E2E-mode build. The authority gate proves retained SSH connection and detected-port payload admission is wired into native preload and runtime-client/environment production routes. An earlier full `pnpm test` exercise ran 3,707 files and 39,083 tests; only two assertions in unchanged `agent-exec-handler.test.ts` failed because the managed terminal already injects `GIT_CONFIG_*` prompt settings. The file passed 10/10 with those inherited settings removed. Final exact-head CI evidence is recorded in the PR description. + +Real transport validation used a macOS Electron headless client against an ephemeral Linux Docker SSH/relay target: + +```bash +ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ + tests/e2e/ssh-docker-relay-perf.spec.ts \ + --config tests/playwright.config.ts \ + --project electron-headless \ + --workers=1 + +ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ + tests/e2e/ssh-cold-activation-restore.spec.ts \ + --config tests/playwright.config.ts \ + --project electron-headless \ + --workers=1 +``` + +The four-test relay suite and the cold-restore journey passed. The relay suite covered streaming, a background ACK-stalled PTY, file/Git pressure, and live terminal input/output before and after SSH disconnect/reconnect; the reconnect case independently read the post-reconnect proof file inside the Linux container. The cold-restore journey proved all six restored SSH terminals remounted and accepted remote input after renderer reload. Repo registration waits on exact renderer catalog ownership and full authority, requires an authoritative host-qualified worktree response, and uses no timing sleep. + +Remaining live gaps are headed paired-Orca-server and headless `orca serve` non-interference, WSL, physical Windows and Linux desktop clients, and a multi-target live fan-out/large-terminal-map benchmark. Docker SSH proves the direct SSH provider/relay path, not paired-runtime parity. + +Current-main reconciliation: + +- #8255 advances paired remote-runtime control and pane backoffs on resume/online. Direct SSH registers its own wake input and must not call or absorb that scheduler; one OS event may wake both ownership domains, but each exact pane finalizes at most once. +- #10986 correctly routes an exact worktree by its own host in multi-host projects. Direct reconnect reuses that worktree-specific precedence only after rejecting any contradictory provenance; it never falls back to project-wide repo ambiguity or focused-runtime ownership. +- #10129 makes capable paired-runtime reasonless close fail closed. Direct SSH cleanup clears transient bindings only: it never emits `session.tabs.close`, retires a tab, or kills a provider PTY. +- #10799 confirms runtime project setup must refuse `ssh:` rather than act locally. The desktop direct-SSH handler likewise rejects `runtime:` hosts, and runtime-owned SSH rows remain under runtime authority. +- #11016 parks terminal views without clearing their PTYs. Direct SSH binding cleanup does not invoke parking, close, or layout mutation; the mounted view observes the atomic PTY-state patch independently. +- #10818 routes folder automation through `getKnownWorktreeById` and ambiguity-aware `getFolderWorkspaceConnectionId`, and publishes agent tabs only after binding the spawned PTY. Direct reconnect uses the same effective folder connection as provenance, rejects mixed ownership, preserves agent state, and never treats an adopted PTY as live under a newer authority without matching binding evidence. +- #10891 preserves legacy first-wins semantics while indexing ID-only hydration lookups. Direct reconnect must not treat `buildWorktreeByIdIndex` as ownership proof: target hydration and reattach resolve a host-qualified worktree first and fail closed on duplicate or contradictory ownership. +- #10915 changes runtime output-frame chunking without changing PTY binding, authority, close intent, or hydration ownership. Direct reconnect does not reset output sequence state or reinterpret runtime frames. + +## Invariants + +### Authoritative provider authority + +1. Main is the sole issuer of `SshProviderEpoch` and `connectionGeneration`. Renderer never increments, orders, parses, or synthesizes either value. +2. Direct SSH state is normalized to `providerEpoch: SshProviderEpoch | null` plus `connectionGeneration?: number`. Main-originated direct SSH state always carries a valid pair. `null` or a missing generation means unknown authority, not zero and not a renderer fallback. +3. A single `rotateSshProviderAuthority(targetId)` helper advances both values atomically before every provider-invalidating transition: new connect ownership, transport replacement or loss, relay/multiplexer replacement or loss, provider disposal, target readoption/reassignment, and permanent target removal. No producer may rotate or publish only one component. +4. `connectionGeneration` remains the existing SSH mutation expectation. Expanding its rotation set closes relay-only mutation races. Provider/reconnect operations compare the full `(providerEpoch, connectionGeneration)` pair; this is the formal composition rule, not two independent clocks. +5. Main registers the provider serving the new pair before broadcasting `connected`. A broadcast-first transition is invalid because it can start a preparation against an unregistered provider with no guaranteed self-heal event. +6. Renderer partial state writers are patches. They preserve both main authority fields and cannot author a `connected` state with a new value. +7. Every boundary that decides equality, copies state, admits retained state, preloads state, reconciles state, or republishes state must preserve both fields. This inventory includes `sshConnectionStatesEqual`, `admitSshConnectionState` and its byte/range checks, public-state projection, `ssh:getState`, `ssh:state-changed`, startup reconnect, runtime-client retained payloads, renderer/runtime SSH state stores, web-file mutation reads, and all fixtures/builders. Allowlisted clones must name both fields; equality treats either field changing as significant. +8. A recovery operation captures one exact `DirectSshAuthority = (targetId, providerEpoch, connectionGeneration)`. Equality is the only permitted authority operation. +9. If a connected event lacks either component, the renderer performs one bounded `ssh:getState` reconciliation with a per-target arrival watermark. The reply may fill authority only if no newer push event arrived and the stored event/status still matches the initiating event; it cannot transition status or resurrect an older `connected` state. If authority remains unknown, authoritative preparation, retry, sync, and snapshot mutation fail closed with `authority-unknown`; disconnect cleanup remains allowed. +10. After every await and inside every authoritative store updater, current state must still name the same connected target and exact authority pair. +11. Supersession is determined by coordinator arrival order and exact equality, never numeric ordering. +12. On any authority change, before new preparation admission, cancel the target's queued work, locally settle obsolete waiter leases, send exactly one cancellation for every affected in-flight provider request ID, mark all late results stale, and retain main-side post-await fences. Terminal finalization for the new authority does not wait for old relay work. +13. Exhausting one target's per-session generation counter rolls the process generation scope and revokes every direct SSH target, not only the target that exhausted its counter. Main invalidates every cached authority and aborts every registered provider request from the old scope before any target can publish or admit work under the new scope. + +### Host-qualified ownership + +1. Direct reconnect work owns only `toSshExecutionHostId(targetId)`. +2. Every coordinator detected-worktree provider invocation carries `(repoId, executionHostId, expectedAuthority, providerRequestId)`. Each renderer consumer separately owns a `waiterLeaseId`; a lease ID never crosses IPC or names provider work. +3. Main resolves a repo by the complete `(repo.id, executionHostId)` identity only after validating all present repo provenance. `executionHostId` and legacy `connectionId` must agree when both are present; a catalog row for which either source names the requested host while the other names another SSH, local, or runtime host makes the host catalog non-authoritative. Zero, contradictory, or multiple matches fail closed. Main never uses explicit-field precedence to hide a contradiction and never falls back to first-match `getRepo(repoId)` for a host-qualified request. +4. A local host request can select only a local repo. A direct SSH host request can select only the matching target/provider. A runtime host is rejected by the desktop handler and must use the existing runtime RPC route. +5. A successful response uses a local or direct-SSH discriminant. The direct-SSH variant cannot be constructed without the resolved execution host and full authority pair. Renderer validates the wire discriminant and rejects a mismatch before any use. +6. Runtime-owned or runtime-transported worktrees remain under the runtime environment scheduler, including SSH execution hosts whose `runtimeOwnerEnvironmentId` names a HUB. +7. Raw repo IDs, paths, UI focus, and unqualified legacy metadata are not ownership evidence. Pre-catalog scope resolution uses explicit worktree/repo provenance and `getExplicitRuntimeEnvironmentIdForWorktree`; it must not use the focused-runtime fallbacks in `getExecutionHostIdForWorktree` or `getRuntimeEnvironmentIdForWorktree`. `runtime:unresolved-owner` and focus-only results are ambiguous, not another host's. Unknown ownership is diagnostic and retryable, but never authoritatively replaced or deleted. +8. All present provenance must agree. Explicit worktree ownership takes precedence only after agreement is proven; repo-derived ownership is a fallback only when explicit worktree ownership is absent. A worktree stamped `ssh:B` with repo-derived `ssh:A`, or any direct-SSH row with an explicit runtime owner, is `contradictory-owner` and is preserved without refresh, merge, retry, or pruning. + +### Merge fencing + +1. Provider results are immutable until all host and authority checks pass. +2. Main revalidates the request host, provider instance, and exact authority after the provider await and before: + - remembering worktree roots; + - pruning persisted lineage; + - stamping or backfilling worktree metadata; or + - returning an authoritative result. +3. Renderer revalidates immediately after the preload/runtime await and before any use of the result, including: + - `routeListingBranchSwitchesThroughGitIdentity`; + - hosted-review sanitation; + - `updateWorktreeGitIdentity`; + - `buildWorktreePurgeState`; + - `worktreesByRepo` or `detectedWorktreesByRepo` merges; and + - best-effort lineage refresh. +4. Host-scoped catalog and lineage snapshots are revalidated before their sole renderer merge. +5. A remote-workspace token and snapshot revision are revalidated after hydration and immediately before session merge/publish. The merge also preserves any terminal-recovery revision newer than the snapshot operation. +6. Stale, superseded, timed-out, and canceled results perform zero authoritative mutations and are not logged as operational errors. + +### Terminal state + +The disconnect action preserves the current call-site predicate: a tab is affected only when `tab.ptyId != null`. A tab without a `ptyId` remains byte-identical even if it has `pendingActivationSpawn` or an inconsistent auxiliary PTY index. + +For each affected tab, the atomic action must: + +- set `tab.ptyId` to `null`; +- empty its `ptyIdsByTabId` entry; +- consume `pendingActivationSpawn`; +- remove pending Codex restart and restart-notice entries for the cleared live PTY; +- preserve `lastKnownRelayPtyIdByTabId` for relay-grace reattach and the `#9911` orphan-safety invariant; and +- leave layouts, deferred SSH sessions, pending reconnect IDs, suppression/shutdown guards, tab IDs, titles, generations, and agent state unchanged. + +The action must not bump activity, sort worktrees, or persist worktree metadata. A repeat after all qualified bindings are clear returns the original store state. +It also must not emit `session.tabs.close`, `session.tabs.closeLifecycle`, provider shutdown, or process signals. Binding loss is not close intent, tab retirement, or proof that a PTY died. + +Reconnect finalization must: + +- use `shouldRetryPaneSpawnOnSshReconnect`; +- include exact direct SSH Git-worktree and folder-workspace keys; +- run synchronously before catalog or provider awaits; +- treat a non-null `ptyId` as live only when transient binding provenance names the current authority and the current tab-wide spawn/reattach attempt has established live authority; +- clear stale binding evidence from a missed disconnect or prior authority before testing retry eligibility; +- keep at most one tab-wide retry attempt in flight per tab and authority; +- join renderer pending-spawn promises only for the same retry attempt; authority or tab-generation advance starts independently, and a late obsolete fresh PTY is rejected and retired; +- accept a spawn or reattach acknowledgement only when its attempt, authority, tab generation, target-qualified PTY, and committed PTY index all match; the first split success establishes the tab fallback and a live continuation lease, and later or post-success-mounted siblings capture and commit through that exact lease without replacing the fallback; +- admit a reattach identity before publishing renderer PTY handlers; revalidate the captured lease after every asynchronous SSH preparation wait and, after the synchronous already-exited delivery case, before error or launch-metadata publication, deferred-state mutation, binding cleanup, or replacement spawn; drop stale owned handlers without killing the durable PTY a current lease may adopt; +- settle retirement of a newly created session-expired fallback when admission rejects it, including after transport destruction, and surface shutdown refusal as unknown, without killing a rejected reattach or cold restore that another current lease may adopt; +- preserve the continuation lease if its primary PTY exits while another split leaf from the same attempt is still activating, then promote the late sibling when it commits; +- on an attempt-one sibling failure or timeout, revoke that attempt and rotate the whole tab once; after attempt two is exhausted, retain its continuation lease for siblings already settling while forbidding attempt three; +- when a split pane detaches to a new tab, project the same exact live or pending authority and retry history to both resulting tabs, including a bound-plus-unbound split or an all-null continuation gap before any leaf binds; the detached null-PTY tab remains activation-pending, and a rejected acknowledgement or detach returns the original relevant maps unchanged; +- permit a tab hydrated, newly discovered, or left unbound after a failed spawn to receive a bounded same-authority corrective bump; +- update all affected workspace buckets in one Zustand publication; and +- leave preparation-only requests, nonqualifying tabs, and every other host unchanged. + +The coordinator keeps separate authority-scoped pending-attempt state and successful-binding state, not a set of bump attempts and not a cached preparation outcome. The live binding carries the exact attempt ID as a continuation lease for every split leaf in that tab generation. A healthy live binding, including a bounded empty-primary activation gap, suppresses correction; an unresolved tab is reconsidered on wake, snapshot completion, and preparation completion with at most one tab-wide attempt in flight. One authority chain preserves its complete attempt history and has a hard limit of two automatic attempts. A timeout taking longer than the former rolling 30-second window cannot age out the first attempt and start a third automatic attempt; later wake, snapshot, and preparation triggers remain exhausted until authority replacement rotates pending, binding, and history state. + +## Design + +### 1. Composed provider authority and host-qualified IPC + +Add `SshProviderEpoch` to the shared SSH types and compose it with the existing main-owned connection generation in `src/main/ssh/ssh-provider-authority.ts`. This module owns atomic rotation and delegates generation storage/assertion to `ssh-connection-generation.ts`; it does not introduce an independently advancing clock. + +The direct SSH state boundary becomes: + +```ts +type SshProviderEpoch = string & { readonly __sshProviderEpoch: unique symbol } +type ProviderRequestId = string & { readonly __providerRequestId: unique symbol } +type WaiterLeaseId = string & { readonly __waiterLeaseId: unique symbol } +type SshExecutionHostId = Extract + +type DirectSshStateAuthority = { + providerEpoch: SshProviderEpoch | null + connectionGeneration?: number +} + +type DirectSshAuthority = { + targetId: string + providerEpoch: SshProviderEpoch + connectionGeneration: number +} +``` + +The epoch wire value is a bounded opaque string. Branding is compile-time only. Main state broadcasts and `ssh:getState` include the pair; renderer state stores it without interpretation. `rotateSshProviderAuthority` is the only transition writer and `assertSshMutationExpectation` observes the generation advanced by that same call. + +Extend preload/main detected-worktree APIs: + +```ts +type LocalDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: typeof LOCAL_EXECUTION_HOST_ID +} + +type DirectSshDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority +} + +type ListDetectedWorktreesArgs = LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest + +type AuthoritativeHost = + | { + kind: 'local' + executionHostId: typeof LOCAL_EXECUTION_HOST_ID + } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + +type HostQualifiedDetectedWorktreeResult = + | { + status: 'complete' | 'non-authoritative' + providerRequestId: ProviderRequestId + repoId: string + authority: AuthoritativeHost + result: DetectedWorktreeListResult + } + | { + providerRequestId: ProviderRequestId + executionHostId: ExecutionHostId + status: + | 'canceled' + | 'timed-out' + | 'stale' + | 'ambiguous-owner' + | 'authority-unknown' + | 'rejected' + } +``` + +`DirectSshDetectedWorktreeRequest` requires the full pair as one `expectedAuthority`; local requests cannot carry it. Construction and runtime admission also require `executionHostId === toSshExecutionHostId(expectedAuthority.targetId)`. Every data-bearing direct-SSH response, including `non-authoritative` metadata fallback, is therefore impossible to construct without both fields. Runtime validation rejects decoded SSH data payloads missing either component even if an untyped or older boundary fabricates one. Main owns the 30-second provider deadline; no renderer-supplied timeout can extend it. The desktop handler rejects runtime hosts. Keep an explicitly unqualified legacy overload only for existing callers during migration; it fails closed when more than one host owns the repo ID and is removed after all callers pass a host. + +Main selects the repo and provider before starting work, captures the provider object and full authority, and checks all three again after `provider.listWorktrees`. A host-qualified SSH response cannot be restamped by renderer as another host. + +Use host-qualified lineage ownership as well: + +```ts +type ListDesktopLineageForHostArgs = + | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority + } + +type HostLineageSnapshot = + | { + authoritative: true + authority: AuthoritativeHost + worktreeLineageById: Record + workspaceLineageByChildKey: Record + } + | { + authoritative: false + executionHostId: ExecutionHostId + reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' + } +``` + +Main filters the snapshot to the requested host. Renderer replaces only an authoritative discriminated host scope; malformed SSH authority is rejected before merge. Direct SSH preparation replaces the current bare `fetchWorktreeLineage()` call with this host-qualified API; it does not substitute `{ forceLocalOwner: true }`. + +Main destructive pruning must use qualified repo/worktree ownership. An existing row `meta.hostId` must agree with the resolved repo host in every case. Only an absent legacy host may be inferred when exactly one stored repo owns the repo ID, preserving today's path-reuse cleanup. When multiple hosts can own the ID, pruning requires row `meta.hostId`; absence or conflict makes the result non-authoritative and preserves the row. Authoritative scans backfill absent `meta.hostId` so legacy rows self-heal. Never use repo-ID-prefix pruning across hosts. + +The shared renderer store and web preload retain their existing non-direct-SSH callers. Preserve the legacy/runtime overload and its argument/echo shape in `web-preload-api.ts`, but do not broaden the web client into direct-SSH coordination: paired web clients cannot subscribe to desktop `ssh:state-changed`, so the proposed total direct-SSH web break is not reachable. Shared API type changes still receive compatibility tests so a runtime-routed web read is not rejected merely because the wrapper dropped its requested host. + +### 2. Exact direct SSH target scope + +Add `src/renderer/src/lib/direct-ssh-target-scope.ts`: + +```ts +type DirectSshGitRepoRef = { + repoId: string + executionHostId: SshExecutionHostId +} + +type DirectSshTargetScope = { + catalogRevision: number + gitRepos: DirectSshGitRepoRef[] + gitWorktreeIds: Set + terminalWorkspaceKeys: Set + lineageWorkspaceKeys: Set + ambiguousOwnerCount: number + contradictoryOwnerCount: number +} +``` + +Resolution rules: + +- Build the expected host with `toSshExecutionHostId(targetId)`. +- Resolve repos by `(repo.id, executionHostId)`. +- Collect explicit worktree host, exact repo-derived host, projected runtime owner, and any restored host evidence before selecting a row. +- If two present sources disagree, classify the row as `contradictory-owner`; preserve it and exclude it from refresh, merge, terminal retry, snapshot projection, and pruning. +- When explicit worktree host is present and no source contradicts it, require it to equal the expected host. Use exact repo-derived ownership only when the explicit worktree host is absent. +- Require `getExplicitRuntimeEnvironmentIdForWorktree` to be `null`; an explicit runtime owner contradicts direct SSH even when another source names the expected SSH host. +- Do not use focused-runtime fallback ownership during pre-catalog reconnect. Focus-only local/runtime results and `runtime:unresolved-owner` are ambiguous. +- Do not use `buildWorktreeByIdIndex`, raw `getKnownWorktreeById`, or another first-wins ID-only lookup as authoritative direct-SSH ownership. Hydration and reattach use an exact host-qualified row or fail closed. +- Accept a folder workspace only when its effective connection is exactly `targetId`, its execution host is expected, runtime owner is `null`, and all candidate repo/group/workspace provenance agrees. +- Treat mixed/conflicting folder provenance as contradictory; duplicate same-host owners and unresolved legacy rows are ambiguous/unowned. +- A parsed live app-SSH PTY can recover a stale-catalog terminal only when no explicit other-host or runtime ownership contradicts it. + +Git refresh uses `gitRepos`; terminal clear/retry uses raw Git IDs and folder keys from `terminalWorkspaceKeys`; unified lineage uses `worktree:` and folder keys from `lineageWorkspaceKeys`; snapshot projection uses `gitWorktreeIds`. Folder workspaces never enter the path-based remote-workspace schema. + +When exact repo rows are missing, use a new host-scoped desktop catalog read rather than focused `fetchRepos()` or an all-desktop refresh: + +```ts +listReposForExecutionHost({ + executionHostId, + expectedAuthority +}) +``` + +Main validates explicit and legacy ownership before producing the host snapshot. A row with contradictory `executionHostId` and `connectionId` cannot be filtered into one host by precedence or silently omitted from the other; a contradiction touching the requested host returns a non-authoritative catalog with no rows. + +The renderer merges the immutable response into only that host scope after a full-authority fence. This action owns a per-host catalog revision and in-flight entry; it does not share `reposFetchGeneration` with focused-runtime or all-host fetches. Thus a concurrent runtime-focused catalog refresh cannot silently supersede direct SSH hydration. +Its authoritative response uses the same `AuthoritativeHost` discriminant as detected worktrees and lineage, so direct-SSH catalog data also requires the complete pair. + +### 3. Bounded and cancelable direct SSH provider scheduler + +Add `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts`. It is owned by the `useIpcEvents` effect and used only for direct SSH coordinator scans. + +Required behavior: + +- at most `DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY` (five) locally unsettled coordinator-owned detected-worktree requests in flight; +- owner-aware round-robin selection by target, with repos submitted incrementally rather than flattening one target into a global FIFO; +- no fixed collection window for an idle singleton request; +- an input key of `(repoId, executionHostId, providerEpoch, connectionGeneration, catalogRevision, authoritative requirement)`; +- join only a currently running logical repo task with the exact same key; +- retain the logical key across a first `retrying` timeout and delete it only on a terminal outcome; +- explicit `complete`, `non-authoritative`, `timed-out`, `cancel-budget-exhausted`, `canceled`, `stale`, and `rejected` outcomes; and +- no console error or degraded count for expected cancellation/supersession. + +Each provider invocation has the existing 30-second main-owned deadline. Main creates an `AbortController`, passes its signal to `SshGitProvider.listWorktrees`, and therefore reaches the multiplexer `rpc.cancel` path on timeout. A transient first timeout changes the repo task to `retrying` and requeues it once at the tail of that target's round-robin lane if the full authority remains current. That repo task promise does not settle, and target lineage/token creation does not start, until the retry completes, reaches its second timeout, is invalidated, or retry admission terminates as `cancel-budget-exhausted`. Add cancellation IPC keyed only by `providerRequestId`; authority advance, target invalidation, last-waiter release, and effect teardown abort matching main requests. Queued requests have no provider request ID and cancel without IPC. + +The first timed-out provider invocation and its leases settle before retry admission. The logical repo task remains pending and acquires a fresh provider request ID plus fresh waiter leases for the retry; preparation waiters never reuse an already-canceled provider identity. + +Cancellation uses waiter leases: + +- `waiterLeaseId` and `providerRequestId` are different opaque types generated independently. A provider request ID names one underlying preload/main/provider invocation; every consumer of that shared invocation receives its own renderer-only waiter lease ID. +- The shared detected-worktree coalescer, not the direct coordinator alone, owns the lease registry so sidebar/filesystem consumers also keep a joined provider invocation alive. +- Canceling or superseding one lease settles only that consumer. It does not call cancellation IPC while any other lease remains. +- Releasing the last lease sends exactly one cancellation IPC carrying the provider request ID and captured host/authority. Main never receives, stores, or accepts waiter lease IDs. +- Main aborts the provider request when that cancellation identity matches or when provider authority is invalidated. It does not reconstruct waiter ownership. +- Local cancellation returns after waiter settlement and, for the last lease, main provider-promise settlement. `ssh-channel-multiplexer` rejects its local provider promise when it sends fire-and-forget `rpc.cancel`; no relay response is awaited. +- The scheduler releases the local slot when the underlying provider promise settles locally. The original relay handler may observe abort later, so this metric is not a hard relay-process concurrency claim. +- Track every locally canceled underlying call as conservative cancel debt on its provider instance. Admission requires `locallyUnsettled + cancelDebt <= DIRECT_SSH_PROVIDER_START_BUDGET` (seven), so five canceled calls permit at most two replacements and repeated cancel/retry cannot create unbounded client-originated work. Debt is not cleared by elapsed time or local promise settlement because neither proves relay completion; it clears only when the owning provider/multiplexer is disposed or replaced. A logical task denied admission by this budget settles terminally as `cancel-budget-exhausted`; it never waits for provider replacement and never leaves the preparation barrier pending indefinitely. A hard bound on handlers surviving disposal is impossible without a relay acknowledgement, so telemetry states this as a seven-start per-provider budget rather than a total remote-process guarantee. + +```ts +type DetectedWorktreeRefreshLease = { + waiterLeaseId: WaiterLeaseId + providerRequestId: ProviderRequestId + result: Promise + release(reason: 'superseded' | 'invalidated' | 'stopped'): void +} +``` + +Each lease has its own settlement promise. Normal provider settlement resolves all remaining leases and removes the provider entry automatically; early `release` is idempotent and settles only that lease as canceled. + +Every other preparation await is bounded too: host-scoped catalog and lineage IPC use five-second deadlines, workspace hydration retains its ten-second deadline, and existing remote-workspace RPC deadlines remain in force. Catalog and lineage waiters accept coordinator cancellation; because their main work does not launch a provider process, a late reply is discarded by the renderer fence rather than holding a scheduler slot. + +The existing runtime project scheduler keeps its own five-worker pool, 250 ms debounce, and 5-second minimum interval. Sidebar and filesystem-event refreshes remain unchanged. Cross-subsystem isolation is intentional; the coordinator's bound is not presented as a renderer-wide bound. + +Keep the public `detectedWorktreeRefreshKey` shape unchanged. Its in-flight entry records a provider invocation identity and a lease map; direct-SSH authoritative work may join only when host and full authority also match. An incompatible entry under the same public key gets a separate underlying invocation rather than an unsafe join. The coordinator's authority/revision key wraps this coalescer without fragmenting compatible sidebar or filesystem-event sharing. The shared coalescer owns provider request IDs and waiter leases; the coordinator owns authority/revision fencing, retry state, and scoped metrics. + +### 4. Per-target reconnect coordinator + +Add `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts`, instantiated once in `useIpcEvents`: + +```ts +type DirectSshAuthority = { + targetId: string + providerEpoch: SshProviderEpoch + connectionGeneration: number +} + +type PreparationInput = DirectSshAuthority & { + catalogRevision: number + repoRefs: DirectSshGitRepoRef[] + authorityRequirement: 'required' | 'allow-metadata-fallback' + snapshotRevision?: number + reason: 'reconnect' | 'initial-hydration' | 'workspace-snapshot' | 'wake-refresh' +} + +type DirectSshReconnectCoordinator = { + requestReconnect(authority: DirectSshAuthority): Promise + prepareOnly(input: PreparationInput): Promise + finalizeHydratedTerminals(authority: DirectSshAuthority): number + correctUnboundTerminals(authority: DirectSshAuthority, reason: CorrectionReason): number + replaceAuthority(authority: DirectSshAuthority): void + invalidate(targetId: string): void + stop(): void +} +``` + +There is no global reconnect wave and no authority-long prepared-outcome cache. Transient per-tab pending/live-binding state is separate and exists only to settle or re-arm terminal recovery. + +`replaceAuthority` compares the complete authority tuple. An exact-equal replacement is a no-op: it does not settle leases, clear pending/live terminal state, cancel provider work, or fragment an overlapping preparation. Only a different tuple performs authority replacement. + +#### Reconnect-finalization flow + +```ts +async function requestReconnect(authority) { + if (!isCurrentConnectedAuthority(authority)) return stale() + + // Authority replacement first settles all obsolete local leases and fences their late work. + coordinator.replaceAuthority(authority) + + // A missed disconnect can leave a non-null PTY from the old provider incarnation. + invalidateStaleDirectSshTargetPtyBindings(authority) + + // Terminal-critical and synchronous: no catalog, Git, lineage, or other target can gate it. + const retried = retryDirectSshTargetPanes(authority) + + // Relay flapping never delays terminal recovery, but it does damp full Git preparation. + if (!hasAuthorityBeenStableFor(authority, RELAY_LOST_STABILIZED_MS)) { + scheduleLatestAuthorityPreparation(authority, RELAY_LOST_STABILIZED_MS) + return terminalOnlyOutcome(retried, 'stabilizing') + } + + const input = await capturePreparationInput(authority, 'reconnect') + if (!input.ok) return terminalOnlyOutcome(retried, input.reason) + + const prepared = await prepare(input.value) + if (!prepared.token || !isCurrentConnectedAuthority(authority)) { + return combine(retried, prepared) + } + + // Catch tabs whose exact ownership or hydration became visible during this target's preparation. + const discoveredRetries = correctUnboundTerminals(authority, 'preparation-complete') + void syncRemoteWorkspaceAfterConnect(prepared.token) + return combine(retried + discoveredRetries, prepared) +} +``` + +The first terminal retry is complete before `capturePreparationInput` performs any await. Target B therefore retries even when target A has five slow provider requests. A same-authority duplicate runs bounded correction: healthy live bindings are no-ops, pending attempts are not duplicated, and failed/unbound tabs can re-arm. When workspace hydration completes, `finalizeHydratedTerminals` reruns against the current authority and handles newly hydrated or still-unbound tabs. + +Preparation for each target progresses independently. While authority remains current, it performs its host-scoped lineage read only after every repo task reaches a terminal state: `complete`, `non-authoritative`, final `timed-out`, `cancel-budget-exhausted`, or `rejected`. Authority-wide `canceled`/`stale` returns without lineage or a token. A first retryable timeout is the nonterminal `retrying` state and cannot release lineage, terminal correction, sync, or token creation. The target never waits for another target's repos or lineage. + +When authority rotates again within `RELAY_LOST_STABILIZED_MS` (currently five seconds), replace the delayed preparation with the latest authority and perform terminal finalization immediately. Only the authority that survives the stabilization window starts catalog/Git/lineage work. A same-authority wake during that window coalesces into the pending latest-authority preparation. This is damping, not an epoch-long result cache. + +#### Preparation-only flow + +`prepareOnly` runs catalog/worktree/lineage preparation and returns a token. It never invokes terminal retry and never starts reconnect sync. An unsolicited snapshot uses this mode, then applies that snapshot with the returned token. + +Reconnect and preparation-only requests may share exact overlapping catalog, repo, or lineage promises through ref-counted waiter leases. Superseding one consumer settles only its lease; it does not abort work still owned by another current consumer. They do not share finalization side effects. + +#### Input-scoped deduplication + +Preparation captures concrete inputs: exact authority pair, catalog revision, sorted repo/host refs, authoritative requirement, and snapshot revision when present. Only currently overlapping operations with identical relevant inputs join. + +Completed entries are removed immediately. Consequently: + +- a later same-authority wake rebroadcast runs another bounded refresh; +- a later snapshot revision runs another bounded preparation; +- a newly discovered repo changes the catalog revision/repo fingerprint and cannot join an older scope; +- a snapshot received during reconnect can share the still-running provider reads; and +- the reconnect call chain can pass its completed token directly to sync/apply without a second preparation. + +The token is an operation result, not a coordinator cache entry: + +```ts +type DirectSshPreparationToken = { + authority: DirectSshAuthority + catalogRevision: number + repoFingerprint: string + authorityRequirement: PreparationInput['authorityRequirement'] + snapshotRevision: number | null + outcome: 'complete' | 'degraded' +} + +type SnapshotApplyToken = DirectSshPreparationToken & { + snapshotRevision: number +} +``` + +An unsolicited snapshot passes its revision into `prepareOnly`, so the returned token is already snapshot-bound. Reconnect preparation returns `snapshotRevision: null`; after `remoteWorkspace.get`, sync revalidates authority and creates a `SnapshotApplyToken` by copying the fetched revision onto that token. `applyRemoteWorkspaceSnapshot` accepts only `SnapshotApplyToken` and requires exact revision equality, so it cannot reuse preparation across incompatible snapshots. + +### 5. Fenced worktree, catalog, and lineage merges + +Refactor detected-worktree listing so provider acquisition and store mutation are separate: + +```ts +const listed = await listDetectedWorktreesForRepoCoalesced(request) +if (!isCurrentHostAuthority(request, listed)) return canceled('stale') + +// No code using listed.result may occur above this fence. +return mergeDetectedWorktrees(listed.result, request) +``` + +`mergeDetectedWorktrees` performs the sole renderer mutation for that result. It owns git-identity routing, review-link sanitation, purge state, and both worktree maps in one fenced path. It must not start an unfenced lineage refresh. + +The existing shared coalesced refresh key already includes `executionHostId` and remains unchanged. Add the full authority and concrete input revision only to the coordinator wrapper key. Do not describe host parsing as new key behavior and do not alter cross-subsystem coalescing. + +Host-scoped catalog and lineage functions likewise return immutable results. Each has one merge entry point that revalidates the current exact authority inside the Zustand updater. A stale result produces zero publications. + +Main uses the same pattern around provider work: + +```ts +const authority = resolveExactProvider(args) +const gitWorktrees = await authority.provider.listWorktrees(repo.path, { signal }) +if (!stillOwnsExactProvider(authority, args.expectedAuthority)) return stale() + +return buildAndCommitAuthoritativeResult(authority, gitWorktrees) +``` + +No prune, root-memory update, or metadata backfill occurs before `stillOwnsExactProvider`. + +### 6. Atomic terminal disconnect and retry + +Add terminal-slice actions: + +```ts +clearDirectSshTargetPtyBindings(targetId: string): number +invalidateStaleDirectSshTargetPtyBindings(authority: DirectSshAuthority): number +retryDirectSshTargetPanes(authority: DirectSshAuthority): number +settleDirectSshPaneRetry(result: DirectSshPaneRetryResult): void +``` + +Put pure projections in `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts`. + +`clearDirectSshTargetPtyBindings` traverses `tabsByWorktree` once, selects exact target scope, then applies the `tab.ptyId != null` predicate. For every affected tab the same atomic projection sets `tab.ptyId` to `null`, empties its `ptyIdsByTabId` entry, consumes `pendingActivationSpawn`, and removes pending Codex restart/restart-notice entries for the cleared live PTY. It preserves `lastKnownRelayPtyIdByTabId`, layouts, deferred SSH sessions, pending reconnect IDs, shutdown/suppression state, IDs, titles, generations, and agent state. It lazily clones only changed workspace arrays and maps, commits one patch, and triggers no activity, sorting, or metadata persistence. Tabs without a current `ptyId`, including those with `pendingActivationSpawn`, are untouched. + +`invalidateStaleDirectSshTargetPtyBindings` validates the authority inside the updater and applies that complete atomic projection to a non-null `ptyId` when its transient `ptyAuthorityByTabId` does not equal the current authority. Snapshot-imported or legacy bindings without current-authority provenance are wake hints, not live bindings. + +`retryDirectSshTargetPanes` validates the exact authority inside the updater, resolves scope from that same state snapshot, applies `shouldRetryPaneSpawnOnSshReconnect` plus stale-binding evidence, excludes only current live-success and pending-attempt tab IDs, and commits one `tabsByWorktree` patch. It records a unique tab-wide attempt outside the success ledger. `settleDirectSshPaneRetry` records current-authority success only after a live PTY binding is committed. A failed or timed-out first attempt rotates the tab once; an exhausted second attempt retains continuation authority for sibling callbacks but cannot start a third attempt. + +`clearTabPtyId` keeps genuine single-PTY exit semantics, but split recovery adds two exact-authority projections: promote an already-bound surviving PTY under the same lease, or preserve the lease and activation suppression across an empty-primary gap until a same-attempt sibling commits or settles. `syncPaneDetachPtyOwnership` likewise projects one current split lease and history to both resulting tabs without spawning, exiting, or changing authority, even when the pending attempt has not produced its first PTY. A pane mounted after the first sibling succeeds captures the retained live lease; every spawn callback rechecks the full authority pair and retires a stale provider PTY. Permanent target removal continues through `src/renderer/src/store/slices/ssh-target-cleanup.ts`, whose deletion of last-known and deferred liveness is invalid for a reconnectable disconnect. + +`applySshConnectionStateChange` receives an explicit origin and becomes orchestration: + +```ts +type SshStateApplyOrigin = 'push' | 'initial-hydration' + +const applySshConnectionStateChange = (targetId, state, origin: SshStateApplyOrigin) => { + const previous = getSshConnectionState(targetId) + setSshConnectionState(targetId, state) + + if (isTerminalFailure(state.status)) { + coordinator.invalidate(targetId) + clearRemoteDetectedAgents(targetId) + clearPortForwards(targetId) + setDetectedPorts(targetId, []) + clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status === 'connected') { + if (!state.providerEpoch || state.connectionGeneration === undefined) { + void reconcileAuthorityOnce({ + targetId, + initiatingEventWatermark: getStateEventWatermark(targetId), + initiatingState: state, + initiatingOrigin: origin + }).then(applyAuthorityPatchIfStillCurrent) + } else { + const authority = { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + + if (origin === 'initial-hydration') { + coordinator.replaceAuthority(authority) + void coordinator.prepareOnly({ + ...authority, + authorityRequirement: 'required', + reason: 'initial-hydration' + }) + } else if ( + previous?.status !== 'connected' || + previous.providerEpoch !== state.providerEpoch || + previous.connectionGeneration !== state.connectionGeneration + ) { + void coordinator.requestReconnect(authority) + } else { + coordinator.correctUnboundTerminals(authority, 'wake-refresh') + void prepareAndSyncWithoutHealthyTerminalRemount({ + ...authority, + reason: 'wake-refresh' + }) + } + } + } +} +``` + +Increment the per-target state-event watermark before applying every push event. `setSshConnectionState` must publish when either authority component changes. Reconciliation may only patch missing authority onto the still-current initiating state; it never enqueues a complete stale state reply. + +The terminal-failure cleanup order remains `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, then atomic PTY binding clear. Preserve the current port-broadcast race defense and detected-agent re-detection behavior. + +An already-connected same-authority rebroadcast is a wake refresh, not a healthy-pane remount. It performs bounded unbound/stale correction, fresh bounded preparation, and sync. Initial hydration always carries `origin: 'initial-hydration'` and uses `prepareOnly`; it is never inferred from the absence of `previous`. A bounded authority reconciliation retains the initiating origin, so filling a retained state's missing pair also cannot become a reconnect transition. Hydrating an already-live state must not remount healthy panes. Workspace session completion invokes `finalizeHydratedTerminals` only for reconnect authorities recorded during that hydration interval, but a failed/unbound attempt remains eligible for later same-authority triggers. + +## Remote-workspace data flows + +### Reconnect sync + +1. Reconnect finalizes qualifying terminals immediately. +2. Its target-scoped preparation returns a token. +3. Before awaiting `remoteWorkspace.get`, `syncRemoteWorkspaceAfterConnect(token)` captures whether the exact target currently has local tabs. This capture-before-await ordering is load-bearing. +4. The function validates the token, obtains the snapshot, and passes the same token to `applyRemoteWorkspaceSnapshot`. +5. Snapshot apply does not prepare again and is preparation-only with respect to coordinator retry; existing snapshot-driven `reconnectPersistedTerminals` behavior remains. +6. For `revision === 0`, mark hydration and publish the current local session only when the pre-await `hasLocalTabs` capture was true. Revalidate authority before upload. Do not recompute that predicate after preparation/hydration, which could overwrite a newer relay snapshot with locally imported state. +7. Snapshot projection and persisted-terminal reconnect receive only host-qualified worktree references from the token's exact target scope. They cannot reset, replace, remove, or reattach sibling SSH, local, WSL, or runtime-owned tabs or their PTY indexes, layouts, active selection, generations, retry state, or live-binding state, even when raw repo/worktree IDs or paths collide. + +### Unsolicited snapshot + +1. Capture current target authority and the incoming snapshot revision. +2. Call `prepareOnly` with those concrete inputs. +3. Wait for existing workspace-session hydration, bounded by the current 10-second deadline. +4. Capture the target terminal-recovery revision and same-ID local tab recovery fields before snapshot projection. +5. Revalidate the token, snapshot revision, arrival order, and recovery revision immediately before merge. If recovery advanced, rebase the projection on the latest local recovery fields rather than applying the stale captured copy. +6. Apply once as preparation-only. For a same-ID tab, preserve any newer local `generation`, pending attempt, successful current-authority binding, and terminal-recovery revision. Remote `generation` is not comparable across clients and cannot overwrite a local retry. Imported `ptyId`/pending reconnect data is a wake hint until `reconnectPersistedTerminals` settles and current-authority binding provenance is recorded. +7. Run the target-scoped, host-qualified `reconnectPersistedTerminals`, then `finalizeHydratedTerminals` for a reconnect authority. A failed reattach clears pending state and re-arms correction; a successful exact reattach atomically retires its pending attempt and records the current binding without an extra bump. +8. If path resolution still reports unknown worktrees, record a degraded result; do not loop unboundedly. + +A later same-authority snapshot always receives a new preparation attempt after earlier work completed. This preserves convergence when another client creates a worktree while the connection stays live. + +Remote snapshot identity is repo-qualified where the schema provides repo identity: resolve by `(executionHostId, repoId, normalizedPath)` and fail closed on ambiguity. For legacy path-only entries, retain the existing resolver only when the path has exactly one candidate in the exact target scope. Two worktrees at the same absolute host path were not proven producible, so this is a robustness invariant rather than a claim that current storage necessarily creates that collision. + +The existing `buildWorktreeByIdIndex` and `reconnectPersistedTerminals` path retains first-wins ID-only compatibility for its current callers. Direct-SSH snapshot apply must pass host-qualified worktree refs through a dedicated overload or pre-resolved map; it cannot hand raw IDs back to an ID-only lookup and recover authority afterward. + +## Failure handling + +- **Unknown authority:** reconcile once behind the per-target arrival watermark; then fail closed with a retryable diagnostic. Do not use a renderer counter or apply the reply's status. +- **Catalog timeout/failure:** retain cached exact-owner scope, record degradation, and continue target preparation and already-completed terminal recovery. +- **One repo is non-authoritative:** keep its safe metadata fallback separate from operational failure and do not authoritatively delete rows. +- **One repo times out:** the provider invocation settles locally and sends best-effort cancel. On the first retryable timeout, keep the logical repo task and target preparation barrier pending in `retrying`, then requeue once at the target lane tail. A second timeout is final and degrades the repo. If current-provider cancel debt denies retry admission, settle the logical task as terminal `cancel-budget-exhausted`; this releases one degraded preparation outcome rather than an early-success token or an indefinite wait. None of these states blocks another target's terminal retry. +- **One repo rejects:** locally settle it without retry unless classified by the existing narrow transient predicate; operational rejection remains distinct from timeout and non-authoritative data. +- **Lineage timeout/failure:** preserve current lineage, mark target preparation degraded, and continue sync with exact cached worktree scope. +- **Authority advance or target invalidation:** synchronously cancel queued work, settle obsolete local waiter leases, send exactly one cancellation for every affected in-flight provider request ID, rotate renderer in-flight inputs and terminal-attempt state, and reject late results at both main and renderer fences. The new authority begins terminal finalization without waiting for relay acknowledgement. +- **Relay flapping:** finalize terminals for every new authority, but replace/defer full preparation until the latest authority survives `RELAY_LOST_STABILIZED_MS`. +- **Missed disconnect/stale binding:** clear only PTY bindings whose transient binding authority is absent or old, retain last-known relay identifiers, then retry under the new authority. +- **Failed terminal spawn/reattach:** settle the attempt as failed, remove it from pending/success state, and allow rate-limited correction on later preparation, hydration, snapshot, or wake triggers. +- **Workspace hydration timeout:** set existing per-target sync error; do not undo terminal or worktree recovery. Snapshot merge never replaces a newer local recovery revision. +- **Coordinator stop:** cancel queued work, locally settle every waiter, send best-effort aborts for unshared provider calls, and return without waiting for relay acknowledgement. Effect cleanup stops coordinator after subscriptions and before disposing its dedicated scheduler. +- **Unknown owner:** preserve state, count it, and retry on later host-qualified catalog input. +- **Contradictory owner:** preserve state, report the conflicting provenance classes without identifiers, and do not retry or mutate that row until a later catalog revision changes the evidence. + +No failure path falls back to unbounded `Promise.all`. + +## Observability + +Emit one aggregate diagnostic per target operation, not per global wave and not per repo/tab. Use `[direct-ssh-reconnect]` for reconnect finalization and `[direct-ssh-prepare]` for preparation-only work. + +Fields: + +- mode and reason; +- terminal panes retried, stale bindings cleared, successful corrections, and terminal-finalization duration; +- catalog outcome and duration; +- repo tasks completed, non-authoritative, retrying, final timed-out, cancel-budget-exhausted, canceled, stale, and rejected; +- direct-scheduler queue-wait and provider-execution duration distributions; +- timeout retry count, local waiter settlements, cancel debt, and replacement admissions delayed by cancel debt; +- peak locally unsettled coordinator-owned detected-worktree concurrency and estimated late-work allowance; +- lineage outcome; +- Git-worktree, folder-workspace, ambiguous-owner, and contradictory-owner counts; +- overlapping request joins; +- authority rotations observed and preparations damped during flapping; +- total target-operation duration. + +Expected supersession/cancellation is debug-level and does not increment degraded/error metrics. Timeout is separate from queue wait and operational rejection. `fetchWorktrees === false` is not used as a failure proxy; the new discriminated result preserves non-authoritative versus rejected outcomes. + +Do not log target IDs, repo IDs, paths, labels, hosts, usernames, credential errors, snapshot content, raw request IDs, or terminal output. A stable per-session opaque target alias may correlate concurrent aggregate events and is discarded at process exit. + +The product event deliberately omits terminal correction failure/re-arm counts, concurrent non-coordinator call counts, and arrival-order discard counts because this implementation has no truthful production observation for them. It does not populate unobserved fields with constant zeroes. + +Typed product telemetry is implemented with one strict identifier-free aggregate event per target operation. Queue and provider percentiles are derived from that operation's real scheduler samples. The seven-day dogfood dashboard/query is operational follow-up outside this repository; this PR does not claim a dashboard artifact. Schema tests reject identifiers and keep queue wait, execution, timeout, operational rejection, cancellation, and stale results distinct. + +The concurrency metric is explicitly `coordinator_owned_direct_ssh_detected_worktree_concurrency`. It measures locally unsettled provider promises and says nothing about runtime lineage RPCs, sidebar's eight-worker pool, filesystem-event calls, catalog/lineage IPC, total application provider concurrency, non-coordinator same-connection calls, or relay handlers finishing after local cancel. + +## Tests + +This implementation registers the worktree scan-count, host/authority, timeout-barrier, and no-cross-host mutation oracles in the existing `git-worktree.refresh-event-semantics` gate. It extends `terminal-provider.ssh-remote-reattach-contract` for direct-SSH binding clear/retry, hydration, folder workspace, paired-close non-interference, and #8255 wake isolation. A new reliability gate is unnecessary because those existing gates own the lifecycle contracts. + +### Main/preload host and authority contract + +- Preload forwards `repoId`, `executionHostId`, the complete expected authority, and `providerRequestId` unchanged; main owns the deadline and never receives `waiterLeaseId`. +- Renderer host intent across local, direct SSH A/B, and runtime-alias catalog rows routes to the exact provider. Fabricated duplicate main-store rows still fail closed as defense in depth. +- Desktop main rejects a runtime execution host. +- Zero or multiple same-host matches fail closed; unqualified legacy calls fail closed when ownership is ambiguous. +- Compile-time fixtures cannot construct a direct-SSH `complete` result or `authoritative: true` lineage snapshot without both authority fields. Runtime admission rejects malformed wire values that omit either field. Local authoritative variants carry neither SSH field. +- A response echoes exact host and complete authority; renderer rejects any host, target, epoch, generation, provider request, or discriminant mismatch. +- Relay loss/replacement, transport loss/replacement, provider disposal, target readoption, and permanent removal rotate epoch and generation in one helper. +- A per-target generation counter exhaustion rolls the process generation scope, revokes every target's old authority and mutation token, and aborts every old-scope provider request before new-scope admission. +- Relay-only replacement rejects both old reconnect/provider work and an old `SshMutationExpectation`. +- A fresh `connected` authority is not broadcast until its provider is registered. +- An old provider result after any rotation performs no root-memory, lineage-prune, or metadata-backfill mutation. +- Cancellation and timeout reach the provider `AbortSignal` and multiplexer `rpc.cancel`. +- Duplicate repo IDs never trigger cross-host repo-prefix lineage pruning. +- Legacy lineage prunes under a unique repo owner, ambiguous legacy rows are preserved, and an authoritative scan backfills `meta.hostId`. +- `sshConnectionStatesEqual`, retained-payload admission, public projection, preload push/get, startup reconnect, runtime retained/client payloads, renderer/runtime stores, and state builders preserve both authority fields. +- Main-originated broadcasts always publish the complete authority pair. Malformed epoch-only or generation-only retained inputs are rejected; compatibility push/get inputs can only remain `authority-unknown` for bounded reconciliation and perform no authoritative mutation. One valid pair change publishes once and an exact duplicate is a no-op. +- Retained-state admission accepts a bounded valid epoch, rejects malformed/oversized epochs, and never strips a valid authority component. +- The web preload compatibility overload preserves requested-host echo for runtime reads; this does not enable direct-SSH coordination in paired web clients. +- A stale `ssh:getState` reply arriving after a disconnect/reconnect push cannot change status or fill authority; a same-watermark reply can fill only missing authority. +- Main host-catalog admission rejects a row whose explicit execution host contradicts its legacy connection host; it returns no authoritative rows instead of selecting or hiding that row by precedence. + +### Renderer worktree/catalog/lineage fences + +- The coordinator in-flight key differs by host, full authority, catalog revision, and authoritative requirement; the shared coalescer key remains unchanged. +- Inputs that differ only by `authorityRequirement` do not join, and the resulting token echoes that exact requirement. +- Exact overlapping requests join; the same key after completion runs again. +- An old result after disconnect/reconnect or relay-only replacement causes zero store publications. +- Git identity, hosted-review links, purge state, both worktree maps, and best-effort lineage remain byte-identical on stale results. +- Local, another SSH target, and runtime owners with the same repo ID remain unchanged. +- Target snapshot hydration and persisted-terminal reconnect use host-qualified worktree references, prune the retry/live/history ledgers of tabs authoritatively deleted from that exact scope, and leave retained or sibling SSH, local, WSL, folder, and runtime tabs, PTY indexes, layouts, active state, generations, and recovery ledgers unchanged. +- A renderer worktree result is admitted only while every current same-ID repo row still has valid, non-contradictory explicit/legacy ownership; malformed or contradictory provenance introduced during the provider await makes the result stale without store mutation. +- A target-scoped catalog fetch cannot be superseded by focused-runtime `fetchRepos`. +- Host-scoped lineage deletes a stale direct SSH row while preserving local, another SSH target, runtime, and unknown-owner rows. +- Runtime-focused UI state cannot redirect direct SSH catalog, worktree, or lineage ownership. +- An ID-only first-wins hydration index containing the same worktree ID under SSH A and SSH B is never authoritative for direct reconnect; a target-B snapshot reattaches only the host-qualified B row or fails closed. +- Pre-catalog direct SSH scope with a focused runtime treats focus fallback as ambiguous and still recovers tabs supported by explicit PTY/host provenance. +- An explicit `ssh:B` worktree with repo-derived `ssh:A` is `contradictory-owner` for both targets and remains byte-identical. The inverse mismatch, an SSH host plus explicit runtime owner, and conflicting folder group/repo connections fail the same way. +- Repo-derived ownership is accepted only when explicit worktree ownership is absent; adding a conflicting explicit owner converts the same row from accepted to preserved/contradictory without cross-host deletion. +- Git 2.25-compatible worktree fallbacks remain unchanged. + +### Direct SSH scheduler/coordinator + +- A singleton reconnect begins terminal finalization immediately without a collection delay. +- Target B terminal finalization completes while all five direct provider slots are occupied by target A. +- Target B's first repo starts after at most one bounded provider deadline when it arrives behind five A calls. +- Round-robin admission prevents a large target from continually reoccupying every released slot. +- Peak coordinator-owned detected-worktree calls is five; runtime/sidebar activity can raise total app concurrency without failing this assertion. +- Joined consumers receive distinct waiter lease IDs and one shared provider request ID. Canceling one settles only that lease while the other completes from the original call; no provider cancel is sent. +- Last-waiter release sends exactly one cancellation for that shared provider request ID. Authority invalidation sends exactly one cancellation for each affected in-flight provider request, including multi-repo preparation; no cancellation is sent merely because one lease leaves while another current-authority lease still owns that invocation. A waiter lease ID presented to main is rejected and cannot abort provider work. +- A coordinator lease joining sidebar/filesystem work cannot abort that work when the coordinator is superseded; the remaining non-coordinator lease keeps the provider request alive. +- A timeout sends `rpc.cancel`, settles locally, and releases its local slot without a relay acknowledgement. +- Repeated timeout replacements never exceed the two-call cancel-debt allowance; denied work settles as terminal `cancel-budget-exhausted` and is reported separately. +- After a first retryable timeout, the repo task is `retrying`: lineage calls, preparation tokens, sync, and snapshot apply remain at zero until the retry settles. +- A successful retry then releases one lineage read and one token; a second timeout or cancel-budget exhaustion releases a degraded result without starving another target. +- An exact-equal `replaceAuthority` call preserves every pending lease, live binding, and overlapping preparation and sends no cancellation. +- A same-authority reconnect rechecks terminals but does not rebump a healthy current-authority binding. +- A newly hydrated tab receives one retry later under the same authority. +- A tab whose exact ownership becomes visible during that target's preparation receives one retry without delaying the first terminal finalization. +- An authority advance with five old slow calls cancels queued work, settles/aborts old leases, starts new terminal finalization immediately, admits new work under cancel-debt rules, and permits zero old main/renderer mutations. +- A changed authority supersedes old target work by arrival, without numeric comparison. +- A same-authority connected rebroadcast performs bounded correction plus preparation/sync without retrying healthy terminals. +- A failed or timed-out pane on attempt one rotates the tab once; attempt-two failure cannot start attempt three or revoke continuation authority from siblings already settling. +- Three authority rotations inside the stabilization window perform three immediate terminal checks but only one full preparation for the final stable authority. +- `prepareOnly` never invokes terminal retry or reconnect sync. +- A preparation-only request shares exact in-flight repo work with reconnect preparation but not reconnect finalization. +- A completed preparation never suppresses a later same-authority wake or snapshot preparation. +- Stop locally settles queued/in-flight waiters without waiting for relay acknowledgement and prevents post-stop finalization. +- One system resume/browser-online event advances #8255 remote-runtime backoffs and direct-SSH wake preparation independently; runtime-owned SSH rows never enter the direct coordinator, and a direct tab already live under current authority is not double-bumped. + +### Atomic terminal recovery + +- Many live tabs/worktrees clear in one store publication; a second clear is a true no-op. +- Only tabs with `tab.ptyId != null` are changed. +- A null-PTY tab with `pendingActivationSpawn` remains byte-identical. +- A live-PTY tab consumes `pendingActivationSpawn` in the same patch that clears its tab and split-pane PTY indexes. +- `lastKnownRelayPtyIdByTabId` survives and the `#9911` orphan predicate remains reconnectable. +- Live split-pane PTY indexes and Codex restart metadata clear. +- Layouts, deferred sessions, pending reconnect IDs, shutdown/suppression state, titles, and agent state remain unchanged. +- No worktree activity, sorting, or metadata persistence occurs. +- Exact target A clear/retry leaves target B, local, WSL, floating, and runtime-owned tabs unchanged even with duplicate repo IDs. +- Exact direct SSH folder workspaces clear/retry; mixed, ambiguous, and runtime folders do not. +- A parsed target PTY recovers a stale-catalog tab only without contradictory ownership. +- A relay/provider replacement after a missed disconnect clears a pre-authority `ptyId` and retries it while preserving last-known relay IDs. +- Binding provenance for another authority or no provenance is stale; a successful current-authority spawn becomes live and suppresses healthy correction. +- One authority chain permits at most two automatic correction attempts even when each timeout exceeds 30 seconds; later same-authority triggers remain exhausted until authority replacement. +- Rejected stale/mismatched acknowledgements preserve every store map and publish nothing. Concurrent split spawn or reattach acknowledgements share one exact attempt; the first establishes the fallback/live lease, and both already-mounted and post-success-mounted siblings join it. +- Primary exit before a sibling binds preserves the exact continuation lease and activation suppression; the sibling then becomes the fallback without a corrective remount. +- Primary and non-primary split detach both preserve exact authority and retry history on the surviving source and detached destination; an all-null pending-only detach preserves the lease before either side binds, and a same-authority correction leaves both live. +- Disconnect retains exact existing ordering and effects for `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, and atomic PTY clear. +- Disconnect and reconnect emit zero paired `session.tabs.close`/`closeLifecycle`, provider shutdowns, or process signals. +- A manually parked direct-SSH worktree follows the same store patch and retry eligibility without invoking parking, close, or layout mutation. + +### Remote-workspace and hydration integration + +- Connect plus sync performs one logical preparation and passes its token through a nonempty snapshot apply without preparing again. +- An unsolicited snapshot joins exact in-flight preparation but starts a new preparation after that work settles. +- A later same-authority snapshot referencing a newly created worktree resolves and imports its tabs. +- Preparation-only snapshot handling never bumps terminal generations. +- Preparation-only means no coordinator generation bump; existing snapshot-driven terminal reattach remains and is finalized afterward. +- Initial hydration with no previous renderer state follows the explicit `initial-hydration` origin into `prepareOnly` and performs zero reconnect retries; reconciliation retains that origin. +- Reconnect finalized before session hydration retries newly hydrated tabs exactly once afterward. +- Immediate finalization bumps a tab, then a snapshot with the same stable tab ID and an older/absent generation cannot reduce the local generation or suppress correction. +- Snapshot hydration that lands after a newer local retry preserves the newer terminal-recovery revision, pending attempt, and current binding provenance. +- Snapshot-imported `ptyId` without current-authority live evidence remains retry-eligible; successful exact target-scoped `reconnectPersistedTerminals` retires the pending attempt and records it live, while failure re-arms it. +- Snapshot projection and reconnect are host-qualified end to end; another SSH target, local, WSL, and runtime-owned state remain byte-identical despite colliding raw IDs or paths. +- Same-authority wake rebroadcast performs bounded fresh discovery. +- Revision-zero sync captures `hasLocalTabs` before `remoteWorkspace.get`, uploads only on that capture, and revalidates authority before publish. +- Snapshot apply rejects a token whose `snapshotRevision` differs, and reconnect sync can create a `SnapshotApplyToken` only from the snapshot fetched by that same fenced operation. +- Repo-qualified snapshot mapping keeps same-named repo paths isolated; a legacy path-only entry applies only with one exact-target candidate and otherwise fails closed. +- Folder workspace keys never enter snapshot projection. +- Hydration timeout does not undo terminal recovery. + +### Performance and diagnostics + +Seed direct SSH targets, a runtime environment, sidebar refreshes, worktrees, folder workspaces, split tabs, and large terminal maps. Assert: + +- coordinator-owned direct SSH detected-worktree concurrency never exceeds five; +- runtime and sidebar work are excluded from, and may exceed, that scoped count; +- terminal retry is submitted before any provider task and uses one publication; +- disconnect uses one publication and schedules session persistence once; +- queue wait, provider duration, timeout, and cancellation are distinct; +- canceled/stale outcomes do not console-error or increment degraded counts; +- diagnostics contain counts/durations plus only an ephemeral target alias; +- the typed telemetry schema rejects identifiers and emits observed queue/provider distributions, timeout/retry/cancel-debt counts, flapping damping, and successful correction results; and +- the adapter computes bounded p50/p95/p99 values from each operation's real scheduler samples. + +## Implementation map + +- `src/main/ssh/ssh-provider-authority.ts` composes epoch issuance with `ssh-connection-generation.ts`; `src/main/ipc/ssh.ts` rotates on every provider transition, registers before `connected`, and publishes the pair. +- `src/shared/ssh-types.ts`, `ssh-retained-payload-admission.ts`, `runtime-client-events.ts`, and public SSH state projection define, validate, and retain the complete authority. `src/preload/api-types.ts`, `src/preload/index.ts`, startup reconnect, runtime SSH state, and web mutation readers copy it unchanged. +- `src/renderer/src/store/slices/ssh-target-cleanup.ts` and `ssh.ts` make the authority pair equality-significant and patch-preserving. The state-event/reconciliation code in `useIpcEvents` owns arrival watermarks. +- `src/main/ipc/worktrees.ts` and the provider authority module own exact host/provider resolution, the provider-request registry, main deadlines/aborts, post-await fences, qualified lineage pruning, and host metadata backfill. Main has no waiter-lease concept. +- `src/renderer/src/store/slices/detected-worktree-refresh-leases.ts` owns the existing public coalescer key, compatible provider invocation entries, independently generated provider request and waiter lease IDs, ref-counted last-waiter cancellation, and per-lease settlement for every caller. +- `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts` acquires shared leases and owns the five local slots, target round-robin, authority/revision wrapper key, nonterminal `retrying` state, one timeout retry, and cancel-debt allowance. +- `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts` owns per-target authority replacement, preparation waiters, stabilization damping, preparation barriers/tokens, pending terminal attempts, success ledger, and bounded corrective triggers. +- `src/renderer/src/lib/direct-ssh-target-scope.ts` uses explicit provenance for Git and folder scope; it never reads focused-runtime ownership. +- Terminal slice actions and `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts` own atomic clear, stale-binding invalidation, transient PTY authority, attempt settlement, and single-publication retry projection. +- `src/renderer/src/hooks/remote-workspace-target-sync.ts` owns capture-before-await revision-zero push, token/revision fences, repo-qualified legacy-safe resolution, local recovery preservation, snapshot-driven reattach, and post-hydration finalization. +- Typed aggregate telemetry owns the privacy schema, per-operation distributions, and fail-soft histogram emission. External dogfood dashboard/query configuration remains an operational follow-up. + +## Rollout + +The implementation preserves this dependency order: + +1. Add the opaque epoch and `rotateSshProviderAuthority`, expand `connectionGeneration` rotation to the same transition set, and inventory every state equality/copy/preload/retained/reconciliation boundary. Land complete-pair publication, malformed partial-authority rejection, retained-admission, stale-reconciliation, provider-before-broadcast, and old-mutation-expectation tests before any coordinator routing. +2. Add host-qualified detected-worktree and lineage IPC, discriminated authoritative response admission, exact main provider selection, main-owned 30-second deadline, provider-request cancellation, and main pre-mutation fences. Preserve the web/runtime overload without enabling direct-SSH web coordination. Keep the coordinator disabled. +3. Refactor renderer worktree/catalog/lineage reads to immutable results with pre-mutation full-authority fences. Add the host-scoped catalog lane independent of focused-runtime supersession, all-provenance contradiction rejection, legacy lineage host backfill, and shared coalescer leases with distinct waiter/provider IDs. +4. Add exact target scope, atomic disconnect clear, stale-authority binding invalidation, retry-attempt settlement, and transient binding provenance. Switch disconnect and reconnect terminal handling together so Git/folder clear-retry symmetry and detected-agent/port cleanup remain intact in every commit. +5. Add the dedicated fair direct SSH scheduler and per-target coordinator with local-settlement semantics, the first-timeout preparation barrier, one timeout retry, cancel-debt admission, authority-advance invalidation, same-authority correction, and flapping damping. Route connected events through reconnect finalization; leave #8255 remote-runtime recovery and runtime-owned SSH rows unchanged. +6. Extract `prepareRemoteWorkspaceTarget`, `syncRemoteWorkspaceAfterConnect`, and `applyRemoteWorkspaceSnapshot` into `src/renderer/src/hooks/remote-workspace-target-sync.ts`. Split preparation-only from reconnect mode, pass the full operation token through revision-zero push and snapshot apply, preserve newer local recovery state, finalize imported terminal hints, and remove repeated preparation calls. +7. Enable typed aggregate diagnostics/telemetry and dogfood with renderer-catalog owner aliasing, many direct targets, an active focused runtime, sidebar activity, folder workspaces, relay-only flapping, timeout/retry/cancel debt, missed disconnects, failed pane spawns, arrival-order races, system resume/browser online events, and cross-device snapshot changes. + +Stage 5 is gated on stages 2–4. The coordinator must never call catalog or lineage APIs whose ownership depends on `settings.activeRuntimeEnvironmentId`; a partial coordinator-first rollout is forbidden. System-resume/browser-online recovery must not double-bump a tab already live under the direct SSH authority ledger. + +Release checks: + +- zero host/authority mismatch accepted at either main or renderer mutation fences; +- zero authority component dropped at equality, retained, preload, startup, public-state, or reconciliation boundaries; +- zero authoritative direct-SSH result constructible or admitted without the full authority pair; +- no cross-host provider selection in duplicate-ID integration tests; +- no contradictory provenance accepted by OR-matching or repo fallback; +- terminal finalization is scheduled before provider discovery and is not delayed by another target or runtime/sidebar work; +- p95/p99 direct scheduler queue wait and provider duration, plus timeout/retry/cancel-debt rates, are reported separately from same-relay non-coordinator traffic; +- peak coordinator-owned direct SSH detected-worktree concurrency is at most five; +- waiter cancellation is lease-local, last-waiter release sends one cancellation for its provider request, authority invalidation sends exactly one per affected in-flight provider request, provider cancellation settles without relay acknowledgement, and cancel debt remains bounded; +- no lineage read, preparation token, or sync starts while a first timed-out repo remains retrying; +- authority advance invalidates old queued/in-flight work and relay flapping produces only one stable preparation wave; +- missed disconnect, failed spawn, and hydration overwrite cases converge through bounded same-authority correction; +- later same-authority snapshots containing new worktrees converge; +- revision-zero push preserves capture-before-await ordering and stale reconciliation cannot resurrect connected state; +- direct SSH lineage deletion is host-correct; and +- Git and folder terminal overlays clear and retry symmetrically while port and detected-agent cleanup remains intact. + +Rollback disables coordinator routing with build-time `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or session key `orca.directSshReconnectCoordinator.enabled=false` while retaining composed authority rotation, authority-boundary preservation, host-qualified IPC, mutation fences, and atomic terminal actions. The fallback reconnect path uses the dedicated bounded scheduler and preserves port/detected-agent cleanup; it does not restore host-blind or unbounded `Promise.all`. + +## Cross-platform and compatibility + +- Use existing execution-host, folder-workspace, workspace-key, SSH PTY-ID, and path utilities. Do not concatenate execution-host IDs or parse filesystem paths. +- The design introduces no keyboard behavior or platform-specific UI. +- Direct SSH on Windows, macOS, Linux, and WSL follows the same host/authority rules. +- Cancellation uses typed provider request IDs over Electron IPC and existing `AbortSignal` support; waiter lease IDs remain renderer-local, and no OS signal semantics or new cancel-ack protocol crosses the wire. +- No Git command, option, parser, or native dependency changes. Git 2.25 compatibility and capability fallbacks remain authoritative. +- Remote runtimes require no server upgrade. Runtime-host requests continue through the existing runtime RPC route. +- Keep new modules within normal line limits; do not add or widen a `max-lines` disable. + +## Rejected alternatives and overreach + +### Treat in-flight coalescing as collapsing the current sequential scan count + +Rejected. The connected refresh, sync preparation, and snapshot preparation await one another. Their provider reads do not overlap, so the current single-flight map has no live promise for the later wave to join. It can reduce unrelated overlapping calls, but does not invalidate the `2R`/`3R` sequential-path diagnosis. + +### Use either main connection generation or provider epoch as an independent clock + +Rejected. The renderer fallback counter has different ownership and is never authoritative. The existing main generation and new opaque epoch are composed by one rotation helper and transition set: mutation consumers compare the generation, provider/recovery consumers compare the pair, and no producer may advance them independently. + +### Cache completed preparation for an entire authority + +Rejected. Stable connections can receive wake refreshes and later snapshots referencing newly created worktrees. Only exact overlapping input work is shared; completed work never suppresses later inputs. + +### Use one global wave and FIFO for direct SSH plus runtime discovery + +Rejected. It couples terminal recovery and target preparation to unrelated queues and timeouts. Direct terminal finalization is synchronous per target; direct provider work uses a dedicated fair bounded scheduler. + +### Let preparation-only reuse reconnect finalization + +Rejected. Snapshot preparation must never initiate coordinator retry or reconnect sync. Existing snapshot-driven persisted-terminal reattach remains part of apply and is reconciled with current-authority binding evidence afterward. Only immutable catalog/repo/lineage reads may be shared. + +### Fence only before the final worktree-map merge + +Rejected. Main lineage/metadata changes and renderer git-identity routing occur earlier. Every authoritative side effect is downstream of an exact host/authority fence. + +### Use `{ forceLocalOwner: true }` for direct SSH lineage + +Rejected. It cannot authoritatively replace an SSH host scope and leaves deleted SSH lineage behind. The wire response and renderer replacement scope must both name the exact execution host. + +### Reuse the component sidebar single-flight coalescer + +Rejected. It is component-scoped, fire-and-forget, and lacks provider-authority, cancellation, terminal, and preparation semantics. The coordinator instead acquires leases from the shared detected-worktree provider coalescer while preserving its public key shape. + +### Replace direct `Promise.all` with `refreshRuntimeProjectWorktrees` + +Rejected. It bounds only one invocation, remains host-blind at the desktop IPC boundary, and does not remove repeated preparation. + +### Use one scheduler per SSH target + +Rejected. It permits `5 × targetCount` locally unsettled provider calls. One dedicated fair scheduler provides a truthful five-local-call bound without a cross-target completion barrier; cancel debt states the separate late-relay allowance. + +### Wait for a relay cancellation acknowledgement + +Rejected. `rpc.cancel` is a notification and canceled handlers intentionally send no response. Local waiter/provider settlement releases scheduler ownership; late effects remain authority-fenced and replacement admission is bounded by cancel debt. + +### Expand direct SSH coordination to the paired web client + +Rejected. Paired web clients cannot subscribe to the desktop direct-SSH state path. Preserve shared overload compatibility and host echoes, but do not create a new direct-SSH web transport to address an unreachable version of the shim claim. + +### Apply a fixed collection debounce + +Rejected. It taxes the singleton common case and is unnecessary when exact overlapping work already coalesces. Fair incremental admission handles multi-target bursts. + +### Call `fetchAllWorktrees` + +Rejected. It refreshes unrelated hosts, weakens failure isolation, and collides with the separate sidebar surface. + +### Reuse permanent target-removal cleanup + +Rejected. Removal intentionally deletes last-known and deferred liveness. Reconnectable disconnect must preserve them. + +### Batch repeated `clearTabPtyId` calls + +Rejected. React batching does not remove Zustand updater work, global map cloning, activity writes, or session debounce resets. + +### Move reconnect recovery into main + +Rejected. Main owns provider authority and mutation fences; renderer owns tab bindings, session projection, and Zustand updates. The host/authority boundary is the smaller ownership correction. diff --git a/examples/plugins/hostile-panel/panel.html b/examples/plugins/hostile-panel/panel.html index d4f779333c6..a612b1e056c 100644 --- a/examples/plugins/hostile-panel/panel.html +++ b/examples/plugins/hostile-panel/panel.html @@ -31,6 +31,12 @@ results.appendChild(li) document.title = 'probes:' + results.children.length } + function markNavigationInvocation(name) { + var marker = document.createElement('meta') + marker.dataset.navigationProbeInvoked = name + marker.content = 'true' + document.head.appendChild(marker) + } // Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'. var cookieValue = 'x' @@ -73,6 +79,7 @@ // Navigation probes are opt-in so a failed attempt cannot erase the // network and bridge evidence before the harness observes it. document.getElementById('navigate-top').addEventListener('click', function () { + markNavigationInvocation('top-navigation') try { window.top.location.href = 'https://example.com/' setTimeout(function () { @@ -84,6 +91,7 @@ }) document.getElementById('navigate-self').addEventListener('click', function () { + markNavigationInvocation('self-navigation') try { window.location.href = 'https://example.com/self-navigation' setTimeout(function () { @@ -95,6 +103,7 @@ }) document.getElementById('navigate-anchor-form').addEventListener('click', function () { + markNavigationInvocation('anchor-form-navigation') var anchor = document.createElement('a') anchor.href = 'https://example.com/anchor-navigation' anchor.textContent = 'Navigation probe' @@ -111,6 +120,7 @@ }) document.getElementById('navigate-meta').addEventListener('click', function () { + markNavigationInvocation('meta-refresh-navigation') var refresh = document.createElement('meta') refresh.httpEquiv = 'refresh' refresh.content = '0;url=https://example.com/meta-refresh' diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0b4b11b4449..7e4ac17933f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Animated, AppState, Linking, type AppStateStatus } from 'react-native' -import * as Clipboard from 'expo-clipboard' import { + Animated, + AppState, + Linking, + type AppStateStatus, BackHandler, FlatList, Image, @@ -17,6 +19,7 @@ import { type LayoutChangeEvent, type ListRenderItem } from 'react-native' +import * as Clipboard from 'expo-clipboard' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import AsyncStorage from '@react-native-async-storage/async-storage' diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts index da4d6096cc2..219dd58dc7b 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -26,6 +26,8 @@ export type MobileSessionTab = /** Agent Orca launched in this terminal, if any. This makes chat eligible * before the first live agent-status update reaches the mobile client. */ launchAgent?: TuiAgent + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string terminalTheme?: MobileTerminalTheme isActive: boolean } diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index a9d24ae637a..f3980bb31b8 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -106,11 +106,9 @@ import { } from '../../../src/tasks/setup-hook-trust' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { triggerMediumImpact } from '../../../src/platform/haptics' -import type { - GitHubProjectSortDirection, - GitHubProjectTable as SharedGitHubProjectTable -} from '../../../src/tasks/mobile-github-project-group-sort' import { + type GitHubProjectSortDirection, + type GitHubProjectTable as SharedGitHubProjectTable, groupRows, isIterationCurrent, sortRows, diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index add1892aee2..abad3a84780 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -10,15 +10,17 @@ import type { RpcClient } from '../transport/rpc-client' import { getWorktreeLabel } from '../session/worktree-label' import { buildMobileAiVaultResumeLaunch, - prepareMobileAiVaultSessionResume, createMobileAiVaultResumeMutationRegistry, readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal, - RESUME_RPC_TIMEOUT_MS, type MobileAiVaultResumeSettings } from '../session/ai-vault-resume-launch' +import { + prepareMobileAiVaultSessionResume, + RESUME_RPC_TIMEOUT_MS +} from '../session/ai-vault-resume-preparation' import { triggerError, triggerSuccess } from '../platform/haptics' import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' import type { Worktree } from '../worktree/workspace-list-types' diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts index 52eb0e5ad2b..20a92663a49 100644 --- a/mobile/src/components/MobileMarkdown.test.ts +++ b/mobile/src/components/MobileMarkdown.test.ts @@ -70,9 +70,64 @@ describe('parseMobileMarkdown', () => { expect(normalizeMobileMarkdownPreviewHtml('Array in prose')).toBe( 'Array in prose' ) + expect(normalizeMobileMarkdownPreviewHtml('Promise in prose')).toBe( + 'Promise in prose' + ) + expect(normalizeMobileMarkdownPreviewHtml('Promise> in prose')).toBe( + 'Promise> in prose' + ) + expect(normalizeMobileMarkdownPreviewHtml('Map> in prose')).toBe( + 'Map> in prose' + ) + expect(normalizeMobileMarkdownPreviewHtml('type Box = { value: T }')).toBe( + 'type Box = { value: T }' + ) + expect( + normalizeMobileMarkdownPreviewHtml( + 'type Box = { value: Value }' + ) + ).toBe('type Box = { value: Value }') + expect(normalizeMobileMarkdownPreviewHtml('a')).toBe('a') + expect(normalizeMobileMarkdownPreviewHtml(' is a type parameter')).toBe( + ' is a type parameter' + ) + expect(normalizeMobileMarkdownPreviewHtml('')).toBe( + '' + ) + expect(normalizeMobileMarkdownPreviewHtml('')).toBe( + '' + ) + expect(normalizeMobileMarkdownPreviewHtml('')).toBe('') expect(normalizeMobileMarkdownPreviewHtml('')).toBe( '' ) + expect( + normalizeMobileMarkdownPreviewHtml( + " hidden" + ) + ).toBe(" hidden") + expect(normalizeMobileMarkdownPreviewHtml('Replace now')).toBe( + 'Replace now' + ) + expect( + normalizeMobileMarkdownPreviewHtml( + ' Replace now' + ) + ).toBe('Replace now') + expect( + normalizeMobileMarkdownPreviewHtml(' Array now') + ).toBe('Array now') + expect( + normalizeMobileMarkdownPreviewHtml( + '> Replace now' + ) + ).toBe('Replace now') + expect( + normalizeMobileMarkdownPreviewHtml('> Array now') + ).toBe('Array now') + expect(normalizeMobileMarkdownPreviewHtml('Use next')).toBe( + 'Use next' + ) expect(normalizeMobileMarkdownPreviewHtml('
Readable text
')).toBe('Readable text') }) @@ -91,4 +146,131 @@ describe('parseMobileMarkdown', () => { `${literalPlaceholder} and \`Array\`` ) }) + + it('strips nested HTML and SVG markup without leaking tag variants', () => { + expect( + normalizeMobileMarkdownPreviewHtml( + '

Logo done

' + ) + ).toBe('Logo done') + expect(normalizeMobileMarkdownPreviewHtml('Logo done')).toBe( + 'Logo done' + ) + expect(normalizeMobileMarkdownPreviewHtml('Logo done')).toBe( + 'Logo done' + ) + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('')).toBe('') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi done')).toBe( + 'LogoHi done' + ) + expect(normalizeMobileMarkdownPreviewHtml('')).toBe('') + expect(normalizeMobileMarkdownPreviewHtml('LogoHithere')).toBe('LogoHithere') + expect(normalizeMobileMarkdownPreviewHtml('Logo

Hi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi')).toBe('LogoHi') + expect(normalizeMobileMarkdownPreviewHtml('Logo

Hi there now')).toBe( + 'LogoHi there now' + ) + expect(normalizeMobileMarkdownPreviewHtml('
Title
')).toBe('Title') + expect(normalizeMobileMarkdownPreviewHtml('Hi')).toBe('Hi') + expect(normalizeMobileMarkdownPreviewHtml('')).toBe('') + expect(normalizeMobileMarkdownPreviewHtml('')).toBe('') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi done')).toBe('LogoHi done') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi done')).toBe('LogoHi done') + expect(normalizeMobileMarkdownPreviewHtml('LogoHi done')).toBe('LogoHi done') + expect(normalizeMobileMarkdownPreviewHtml('<svg><path/></svg>')).toBe( + '' + ) + expect(normalizeMobileMarkdownPreviewHtml('

Use <svg> icons

')).toBe( + 'Use icons' + ) + expect(normalizeMobileMarkdownPreviewHtml('

x

')).toBe('x') + expect(normalizeMobileMarkdownPreviewHtml("

x

")).toBe('x') + expect(normalizeMobileMarkdownPreviewHtml('a > b')).toBe('a > b') + expect( + normalizeMobileMarkdownPreviewHtml('link') + ).toBe('[link](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'first second' + ) + ).toBe('first second') + expect( + normalizeMobileMarkdownPreviewHtml( + 'custom real' + ) + ).toBe('custom [real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'real' + ) + ).toBe('[real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'real' + ) + ).toBe('[real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml('plain') + ).toBe('plain') + expect(normalizeMobileMarkdownPreviewHtml('right')).toBe('right') + expect( + normalizeMobileMarkdownPreviewHtml( + 'custom real' + ) + ).toBe('custom [real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'bad real' + ) + ).toBe('bad [real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'bad real' + ) + ).toBe('bad [real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'bad real' + ) + ).toBe('bad [real](https://example.com)') + expect( + normalizeMobileMarkdownPreviewHtml( + 'before

]+)`, 'i') - const match = tag.match(pattern) - const raw = match?.[1] ?? '' - return decodeHtmlEntities(raw.replace(/^["']|["']$/g, '')) + let cursor = 1 + while (cursor < tag.length && !/[\s/>]/.test(tag[cursor] ?? '')) { + cursor += 1 + } + while (cursor < tag.length) { + while (/\s/.test(tag[cursor] ?? '')) { + cursor += 1 + } + if (tag[cursor] === '>' || (tag[cursor] === '/' && tag[cursor + 1] === '>')) { + return '' + } + + const attributeStart = cursor + while (!/[\s=/>]/.test(tag[cursor] ?? '>')) { + cursor += 1 + } + if (attributeStart === cursor) { + cursor += 1 + continue + } + const attributeName = tag.slice(attributeStart, cursor) + while (/\s/.test(tag[cursor] ?? '')) { + cursor += 1 + } + if (tag[cursor] !== '=') { + continue + } + + cursor += 1 + while (/\s/.test(tag[cursor] ?? '')) { + cursor += 1 + } + const quote = tag[cursor] === '"' || tag[cursor] === "'" ? tag[cursor] : '' + if (quote) { + cursor += 1 + } + const valueStart = cursor + if (quote) { + const valueEnd = tag.indexOf(quote, cursor) + if (valueEnd < 0) { + return '' + } + cursor = valueEnd + 1 + if (attributeName.toLowerCase() === name) { + return decodeHtmlEntities(tag.slice(valueStart, valueEnd)) + } + continue + } + + while (!/[\s>]/.test(tag[cursor] ?? '>')) { + cursor += 1 + } + if (attributeName.toLowerCase() === name) { + return decodeHtmlEntities(tag.slice(valueStart, cursor)) + } + } + return '' +} + +const tagAttributesSource = `(?:[^<>"']|"[^"]*"|'[^']*')*` +const imageTagPattern = new RegExp(``, 'gi') + +function normalizeAnchorTags(value: string): string { + const lowerValue = value.toLowerCase() + let output = '' + let copyCursor = 0 + let searchCursor = 0 + let closingStart = -1 + + while (searchCursor < value.length) { + const start = findNextPairedMarkupOpener(value, lowerValue, 'a', searchCursor) + if (start < 0) { + break + } + const end = findMobileMarkdownMarkupTagEnd(value, start + 2) + if (end < 0) { + if (end === -2) { + break + } + searchCursor = start + 2 + continue + } + if (!isPairedMarkupOpener(value, start + 2, end)) { + searchCursor = end + 1 + continue + } + + const tag = value.slice(start, end + 1) + const href = attrValue(tag, 'href') + if (!href) { + searchCursor = end + 1 + continue + } + + if (closingStart < end + 1) { + closingStart = lowerValue.indexOf('', end + 1) + } + if (closingStart < 0) { + break + } + const nestedStart = findNextPairedMarkupOpener(value, lowerValue, 'a', end + 1) + if (nestedStart >= 0 && nestedStart < closingStart) { + searchCursor = nestedStart + continue + } + + const text = stripTags(value.slice(end + 1, closingStart)) + output += value.slice(copyCursor, start) + output += href && text ? `[${text}](${href})` : text + copyCursor = closingStart + 4 + searchCursor = copyCursor + } + + return output + value.slice(copyCursor) } function normalizeInlineHtml(value: string): string { - return value + const imagesNormalized = value .replace(//gi, '\n') - .replace(/]*>/gi, (tag) => attrValue(tag, 'alt') || 'image') - .replace( - /]*href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)[^>]*>([\s\S]*?)<\/a>/gi, - (tag, _href, label) => { - const href = attrValue(tag, 'href') - const text = stripTags(label) - return href && text ? `[${text}](${href})` : text - } - ) - .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => { - const text = stripTags(inner) - return text ? `**${text}**` : '' - }) - .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => { - const text = stripTags(inner) - return text ? `*${text}*` : '' - }) - .replace(/<(code|kbd)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => { - const text = stripTags(inner) - return text ? `\`${text}\`` : '' - }) + .replace(imageTagPattern, (tag) => attrValue(tag, 'alt') || 'image') + + let next = normalizeAnchorTags(imagesNormalized) + next = replaceMobileMarkdownPairedMarkupTags(next, ['strong', 'b'], (_name, inner) => { + const text = stripTags(inner) + return text ? `**${text}**` : '' + }) + next = replaceMobileMarkdownPairedMarkupTags(next, ['em', 'i'], (_name, inner) => { + const text = stripTags(inner) + return text ? `*${text}*` : '' + }) + next = replaceMobileMarkdownPairedMarkupTags(next, ['code', 'kbd'], (_name, inner) => { + const text = stripTags(inner) + return text ? `\`${text}\`` : '' + }) + return next } // Why: Markdown code is literal source, so it must bypass the HTML strip pass. @@ -174,15 +270,19 @@ export function normalizeMobileMarkdownPreviewHtml(content: string): string { // Why: repository Markdown often uses small HTML islands for centered README // headers and badges. Preview mode should read like Markdown, while Source // mode remains the exact file bytes. - next = next.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_tag, level, inner) => { - const text = stripTags(normalizeInlineHtml(inner)) - return text ? `\n${'#'.repeat(Number(level))} ${text}\n` : '\n' - }) - next = next.replace(/]*>([\s\S]*?)<\/p>/gi, (_tag, inner) => { + next = replaceMobileMarkdownPairedMarkupTags( + next, + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + (name, inner) => { + const text = stripTags(normalizeInlineHtml(inner)) + return text ? `\n${'#'.repeat(Number(name.slice(1)))} ${text}\n` : '\n' + } + ) + next = replaceMobileMarkdownPairedMarkupTags(next, ['p'], (_name, inner) => { const text = stripTags(normalizeInlineHtml(inner)) return text ? `\n${text}\n` : '\n' }) - next = next.replace(/]*>([\s\S]*?)<\/sub>/gi, (_tag, inner) => + next = replaceMobileMarkdownPairedMarkupTags(next, ['sub'], (_name, inner) => stripTags(normalizeInlineHtml(inner)) ) next = normalizeInlineHtml(next) diff --git a/mobile/src/components/mobile-markdown-preview-tag-stripper.ts b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts new file mode 100644 index 00000000000..90e3c3cc3cc --- /dev/null +++ b/mobile/src/components/mobile-markdown-preview-tag-stripper.ts @@ -0,0 +1,324 @@ +const knownMarkupTagNames = new Set( + `a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var video wbr fencedframe portal selectedcontent +acronym applet basefont bgsound big blink center command content dir element font frame frameset isindex keygen listing marquee menuitem multicol nextid nobr noembed noframes noindex param plaintext rb rtc shadow spacer strike tt xmp +animate animatemotion animatetransform circle clippath defs desc ellipse feblend fecolormatrix fecomponenttransfer fecomposite feconvolvematrix fediffuselighting fedisplacementmap fedistantlight fedropshadow feflood fefunca fefuncb fefuncg fefuncr fegaussianblur feimage femerge femergenode femorphology feoffset fepointlight fespecularlighting fespotlight fetile feturbulence filter foreignobject g hatch hatchpath image line lineargradient marker mask metadata mpath path pattern polygon polyline radialgradient rect discard set stop svg switch symbol text textpath tspan use view +altglyph altglyphdef altglyphitem animatecolor cursor font-face font-face-format font-face-name font-face-src font-face-uri glyph glyphref hkern missing-glyph solidcolor vkern +annotation annotation-xml maction math menclose merror mfenced mfrac mi mmultiscripts mn mo mover mpadded mphantom mprescripts mroot mrow ms mspace msqrt mstyle msub msubsup msup mtable mtd mtext mtr munder munderover semantics`.split( + /\s+/ + ) +) + +function followsAttributeEquals(value: string, index: number): boolean { + let previous = index - 1 + while (previous >= 0 && /\s/.test(value[previous] ?? '')) { + previous -= 1 + } + return value[previous] === '=' +} + +export function findMobileMarkdownMarkupTagEnd(value: string, start: number): number { + let quote = '' + for (let index = start; index < value.length; index += 1) { + const char = value[index] ?? '' + if (quote) { + if (char === quote) { + quote = '' + } + continue + } + if ((char === '"' || char === "'") && followsAttributeEquals(value, index)) { + quote = char + } else if (char === '<') { + return -1 + } else if (char === '>') { + return index + } + } + if (quote) { + for (let index = start; index < value.length; index += 1) { + const char = value[index] ?? '' + if (char === '<') { + return -1 + } + if (char === '>') { + return index + } + } + } + return -2 +} + +function nextNestedMarkupCursor(value: string, start: number): number { + const nestedStart = value.indexOf('<', start + 1) + if (!followsAttributeEquals(value, nestedStart)) { + return nestedStart + } + const nestedEnd = findMobileMarkdownMarkupTagEnd(value, nestedStart + 1) + if (nestedEnd < 0) { + return nestedStart + } + return nestedEnd + 1 + Number(value[nestedEnd + 1] === '>') +} + +function isMarkupNameStart(char: string): boolean { + const code = char.charCodeAt(0) + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) +} + +const markupNameCharPattern = /^[A-Za-z0-9:-]$/ + +function isMarkupNameChar(char: string): boolean { + return markupNameCharPattern.test(char) +} + +function isPairedMarkupOpenerBoundary(char: string): boolean { + return char === '>' || /\s/.test(char) +} + +export function isPairedMarkupOpener(value: string, nameEnd: number, end: number): boolean { + if (!isPairedMarkupOpenerBoundary(value[nameEnd] ?? '')) { + return false + } + let lastContent = end - 1 + while (lastContent >= nameEnd && /\s/.test(value[lastContent] ?? '')) { + lastContent -= 1 + } + return value[lastContent] !== '/' +} + +export function findNextPairedMarkupOpener( + value: string, + lowerValue: string, + name: string, + cursor: number +): number { + let start = lowerValue.indexOf(`<${name}`, cursor) + while (start >= 0) { + const nameEnd = start + name.length + 1 + const end = findMobileMarkdownMarkupTagEnd(value, nameEnd) + if (end >= 0 && isPairedMarkupOpener(value, nameEnd, end)) { + return start + } + start = lowerValue.indexOf(`<${name}`, start + name.length + 1) + } + return -1 +} + +export function replaceMobileMarkdownPairedMarkupTags( + value: string, + tagNames: readonly string[], + replacement: (name: string, inner: string) => string +): string { + const lowerValue = value.toLowerCase() + const activeNames = new Set(tagNames) + const nextStarts = new Map() + const nextClosingStarts = new Map() + let output = '' + let copyCursor = 0 + let searchCursor = 0 + + while (activeNames.size > 0 && searchCursor < value.length) { + let name = '' + let start = -1 + for (const candidate of activeNames) { + let candidateStart = nextStarts.get(candidate) ?? -1 + if (candidateStart < searchCursor) { + candidateStart = findNextPairedMarkupOpener(value, lowerValue, candidate, searchCursor) + if (candidateStart < 0) { + activeNames.delete(candidate) + nextStarts.delete(candidate) + continue + } + nextStarts.set(candidate, candidateStart) + } + if (start < 0 || candidateStart < start) { + name = candidate + start = candidateStart + } + } + if (start < 0) { + break + } + + const nameEnd = start + name.length + 1 + nextStarts.delete(name) + const end = findMobileMarkdownMarkupTagEnd(value, nameEnd) + + const closingTag = `` + let closingStart = nextClosingStarts.get(name) ?? -1 + if (closingStart < end + 1) { + closingStart = lowerValue.indexOf(closingTag, end + 1) + nextClosingStarts.set(name, closingStart) + } + if (closingStart < 0) { + // No later opener of this name can match once its final closer is behind us. + activeNames.delete(name) + searchCursor = start + 1 + continue + } + const nestedStart = findNextPairedMarkupOpener(value, lowerValue, name, end + 1) + if (nestedStart >= 0 && nestedStart < closingStart) { + nextStarts.set(name, nestedStart) + searchCursor = nestedStart + continue + } + + output += value.slice(copyCursor, start) + output += replacement(name, value.slice(end + 1, closingStart)) + copyCursor = closingStart + closingTag.length + searchCursor = copyCursor + } + + return output + value.slice(copyCursor) +} + +function isPlaceholderSuffix(value: string, start: number, end: number): boolean { + let lastNonSpace = '' + for (let index = start; index < end; index += 1) { + const char = value[index] ?? '' + if (char === '=') { + return false + } + if (!/\s/.test(char)) { + lastNonSpace = char + } + } + return lastNonSpace !== '/' +} + +function closingMarkupTagNames(value: string): Set { + const names = new Set() + let cursor = 0 + while (cursor < value.length) { + const start = value.indexOf('<', cursor) + if (start < 0) { + return names + } + const end = findMobileMarkdownMarkupTagEnd(value, start + 1) + if (end < 0) { + if (end === -2) { + return names + } + cursor = nextNestedMarkupCursor(value, start) + continue + } + if (value[start + 1] !== '/' || !isMarkupNameStart(value[start + 2] ?? '')) { + cursor = end + 1 + continue + } + let nameEnd = start + 3 + while (isMarkupNameChar(value[nameEnd] ?? '')) { + nameEnd += 1 + } + let boundary = nameEnd + while (/\s/.test(value[boundary] ?? '')) { + boundary += 1 + } + if (boundary === end) { + names.add(value.slice(start + 2, nameEnd).toLowerCase()) + } + cursor = end + 1 + } + return names +} + +export function stripMobileMarkdownMarkupTags(value: string): string { + let output = '' + let cursor = 0 + const closingTagNames = closingMarkupTagNames(value) + while (cursor < value.length) { + const start = value.indexOf('<', cursor) + if (start < 0) { + return output + value.slice(cursor) + } + output += value.slice(cursor, start) + + const isClosing = value[start + 1] === '/' + const nameStart = start + (isClosing ? 2 : 1) + if (!isMarkupNameStart(value[nameStart] ?? '')) { + output += '<' + cursor = start + 1 + continue + } + + let nameEnd = nameStart + 1 + while (isMarkupNameChar(value[nameEnd] ?? '')) { + nameEnd += 1 + } + + const previousChar = value[start - 1] + const name = value.slice(nameStart, nameEnd) + const lowerName = name.toLowerCase() + const end = findMobileMarkdownMarkupTagEnd(value, nameEnd) + if (end < 0) { + if (end === -2) { + return output + value.slice(start) + } + const nestedStart = value.indexOf('<', nameEnd) + const isNestedGeneric = + Boolean(previousChar && /\w/.test(previousChar)) && + !isClosing && + !/[-:]/.test(name) && + !knownMarkupTagNames.has(lowerName) && + !closingTagNames.has(lowerName) + if (isNestedGeneric && nestedStart >= 0) { + output += value.slice(start, nestedStart) + cursor = nestedStart + continue + } + cursor = nextNestedMarkupCursor(value, start) + continue + } + + const suffixStart = value[nameEnd] ?? '' + const isKnownMarkup = knownMarkupTagNames.has(lowerName) + const canPreserveOpening = !isClosing && !closingTagNames.has(lowerName) + const isAutolink = + /^<[A-Za-z][A-Za-z0-9+.-]+:[^\s<>]*>$/.test(value.slice(start, end + 1)) && + nameEnd < end && + canPreserveOpening + const isComparisonAngleText = + name.length === 1 && + suffixStart === '=' && + Boolean(previousChar && /\w/.test(previousChar)) && + !/\w/.test(value[start - 2] ?? '') && + canPreserveOpening + const isGeneric = + Boolean(previousChar && /\w/.test(previousChar)) && + canPreserveOpening && + nameEnd === end && + !/[-:]/.test(name) && + !isKnownMarkup + const isTypeParameter = + canPreserveOpening && nameEnd === end && /^[A-Z]$/.test(name) && !isKnownMarkup + const tagSuffix = value.slice(nameEnd, end) + const hasGenericDefault = + /^\s*=/.test(tagSuffix) || /^\s*(?:extends\b|,)[\s\S]*=/.test(tagSuffix) + const isGenericDefault = + Boolean(previousChar && /\w/.test(previousChar)) && + canPreserveOpening && + /^[A-Z][A-Za-z0-9]*$/.test(name) && + !isKnownMarkup && + hasGenericDefault && + !/\/\s*$/.test(tagSuffix) + const isUnpairedPlaceholder = + canPreserveOpening && + !isKnownMarkup && + isPlaceholderSuffix(value, nameEnd, end) && + (!name.includes('-') || tagSuffix.trim().length === 0) && + !name.includes(':') + + if ( + isAutolink || + isComparisonAngleText || + isGeneric || + isTypeParameter || + isGenericDefault || + isUnpairedPlaceholder + ) { + output += value.slice(start, end + 1) + } + cursor = end + 1 + } + return output +} diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index 803964040b5..9efc0e50f1a 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -8,9 +8,9 @@ import { readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, - resumeAiVaultSessionInTerminal, - RESUME_RPC_TIMEOUT_MS + resumeAiVaultSessionInTerminal } from './ai-vault-resume-launch' +import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation' function session(overrides: Partial = {}): AiVaultSession { return { diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 24c3a92e41d..bb8a1e07ad9 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -4,10 +4,7 @@ import { buildAiVaultResumeShellCommand, realHomeCodexResumeEnvDeletion } from '../../../src/shared/ai-vault-types' -import { - isAiVaultPrepareSessionResumeUnavailableError, - isLegacySharedCodexHome -} from '../../../src/shared/ai-vault-resume-preparation' +import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation' import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' @@ -167,43 +164,6 @@ function normalizeMobileAiVaultResumeCommandOverrides( return normalized } -// Why: without an explicit timeout, a socket drop mid-resume parks the request -// on the reconnect waiter for the full reconnect budget, pinning the spinner. -export const RESUME_RPC_TIMEOUT_MS = 30_000 - -export async function prepareMobileAiVaultSessionResume( - client: Pick, - session: AiVaultSession -): Promise { - if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) { - return session - } - const response = await client.sendRequest( - 'aiVault.prepareSessionResume', - { - agent: session.agent, - filePath: session.filePath, - codexHome: session.codexHome, - executionHostId: session.executionHostId - }, - { timeoutMs: RESUME_RPC_TIMEOUT_MS } - ) - if (!response.ok) { - if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) { - // Why: older hosts cannot prepare, but their shared home still supports the legacy resume path. - return session - } - throw new Error( - response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.' - ) - } - const result = response.result as { useRealCodexHome?: unknown } | null - if (result?.useRealCodexHome !== true) { - return session - } - return { ...session, codexHome: null } -} - export async function resumeAiVaultSessionInTerminal( client: Pick, worktreeId: string, diff --git a/mobile/src/session/ai-vault-resume-preparation.test.ts b/mobile/src/session/ai-vault-resume-preparation.test.ts index 12c75ab55b3..a0eed5515c6 100644 --- a/mobile/src/session/ai-vault-resume-preparation.test.ts +++ b/mobile/src/session/ai-vault-resume-preparation.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' import { buildMobileAiVaultResumeLaunch, - prepareMobileAiVaultSessionResume, - resumeAiVaultSessionInTerminal, - RESUME_RPC_TIMEOUT_MS + resumeAiVaultSessionInTerminal } from './ai-vault-resume-launch' +import { + prepareMobileAiVaultSessionResume, + RESUME_RPC_TIMEOUT_MS +} from './ai-vault-resume-preparation' const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' +const PER_ACCOUNT_HOME = '/Users/ada/Library/Application Support/orca/codex-accounts/a/home' function legacySession(overrides: Partial = {}): AiVaultSession { return { @@ -121,10 +124,11 @@ describe('prepareMobileAiVaultSessionResume', () => { { agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' }, { agent: 'codex' as const, - codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home' + codexHome: PER_ACCOUNT_HOME, + executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId'] }, { agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' } - ])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => { + ])('does not prepare unrepinnable session $agent at $codexHome', async (overrides) => { const current = legacySession(overrides) const sendRequest = vi.fn() @@ -132,6 +136,51 @@ describe('prepareMobileAiVaultSessionResume', () => { expect(sendRequest).not.toHaveBeenCalled() }) + it('repins a per-account session to the home the host substitutes', async () => { + const substituteHome = '/Users/ada/Library/Application Support/orca/codex-accounts/b/home' + const current = legacySession({ codexHome: PER_ACCOUNT_HOME }) + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: false, substituteCodexHome: substituteHome } + }) + + const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, current) + const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' }) + + expect(sendRequest).toHaveBeenCalledWith( + 'aiVault.prepareSessionResume', + { + agent: 'codex', + filePath: current.filePath, + codexHome: PER_ACCOUNT_HOME, + executionHostId: 'local' + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(prepared.codexHome).toBe(substituteHome) + expect(launch.command).toContain(`CODEX_HOME='${substituteHome}'`) + }) + + it('keeps a per-account session home when an older host sends no repin', async () => { + const current = legacySession({ codexHome: PER_ACCOUNT_HOME }) + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { useRealCodexHome: false } + }) + + await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current) + }) + + it('keeps a per-account session usable when the host cannot prepare at all', async () => { + const current = legacySession({ codexHome: PER_ACCOUNT_HOME }) + const sendRequest = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' } + }) + + await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current) + }) + it.each([ { code: 'internal_error', diff --git a/mobile/src/session/ai-vault-resume-preparation.ts b/mobile/src/session/ai-vault-resume-preparation.ts new file mode 100644 index 00000000000..4265442ff66 --- /dev/null +++ b/mobile/src/session/ai-vault-resume-preparation.ts @@ -0,0 +1,60 @@ +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + isAiVaultPrepareSessionResumeUnavailableError, + isLegacySharedCodexHome, + isPerAccountManagedCodexHome +} from '../../../src/shared/ai-vault-resume-preparation' +import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host' +import type { RpcClient } from '../transport/rpc-client' + +// Why: without an explicit timeout, a socket drop mid-resume parks the request +// on the reconnect waiter for the full reconnect budget, pinning the spinner. +export const RESUME_RPC_TIMEOUT_MS = 30_000 + +export async function prepareMobileAiVaultSessionResume( + client: Pick, + session: AiVaultSession +): Promise { + // Why: per-account repinning runs on the serving host, whose account + // selection only applies to that host's own ("local") sessions. + const needsAccountRepin = + isPerAccountManagedCodexHome(session.codexHome) && + (!session.executionHostId || session.executionHostId === LOCAL_EXECUTION_HOST_ID) + if ( + session.agent !== 'codex' || + (!isLegacySharedCodexHome(session.codexHome) && !needsAccountRepin) + ) { + return session + } + const response = await client.sendRequest( + 'aiVault.prepareSessionResume', + { + agent: session.agent, + filePath: session.filePath, + codexHome: session.codexHome, + executionHostId: session.executionHostId + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!response.ok) { + if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) { + // Why: older hosts cannot prepare, but their shared home still supports the legacy resume path. + return session + } + throw new Error( + response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.' + ) + } + const result = response.result as { + useRealCodexHome?: unknown + substituteCodexHome?: unknown + } | null + if (result?.useRealCodexHome === true) { + return { ...session, codexHome: null } + } + // Why: older hosts never send a repin home, so absence keeps the session's own home. + if (typeof result?.substituteCodexHome === 'string' && result.substituteCodexHome) { + return { ...session, codexHome: result.substituteCodexHome } + } + return session +} diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 84e0e00d012..988e56c67a4 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -25,6 +25,8 @@ export type MobileNativeChatTab = { type: string launchAgent?: string | null agentStatus?: AgentStatusEntry | null + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string } /** Resolve a session tab to the transcript identity native chat needs, or diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 2f4e5fb9b8d..1f3d57985ac 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -169,6 +169,52 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe('rejected') }) + it('prepends the input-line clear byte when clearInputFirst is set', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + clearInputFirst: true + }) + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: '\x15hello', + enter: true + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) + }) + + it('sends the text verbatim when clearInputFirst is not set', async () => { + // An image send pastes the image (behind its own leading Ctrl+U) before this + // text write; a clear byte here would kill the pasted image off the input line. + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'what is this', + clearInputFirst: false + }) + const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string } + expect(sent.text).toBe('what is this') + expect(sent.text.startsWith('\x15')).toBe(false) + }) + it('sends a single non-submitting Escape for prompt cancellation', async () => { const client = clientWithResponse({ id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 6bce66e9bfa..ec8a0f97861 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -8,11 +8,17 @@ type MobileTerminalClient = { type: 'mobile' } +// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the +// same byte before its body), so a launch-context prefill parked there cannot +// concatenate with a mobile chat message. The host writes text bytes verbatim. +const CLEAR_UNSUBMITTED_INPUT = '\x15' + type MobileNativeChatSendArgs = { client: RpcClient terminal: string text: string enter?: boolean + clearInputFirst?: boolean mobileClient?: MobileTerminalClient /** Shared budget for a whole user action (heal → paste → text, or one selector's * keystroke sequence). Omit to give this write its own full budget. */ @@ -51,7 +57,7 @@ export async function sendMobileNativeChatMessageWithOutcome( 'terminal.send', { terminal: args.terminal, - text: args.text, + text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, ...(args.mobileClient ? { client: args.mobileClient } : {}) }, diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index 0ebd2c55631..fba0774261c 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -87,6 +87,29 @@ describe('mobile terminal records', () => { ).toEqual([]) }) + it('treats a launch draft appearing or retracting as a session-tab change', () => { + // The route keeps `prev` when these compare equal, so a frame whose only + // delta is the draft would never reach the chat composer. + const base: MobileTerminalSessionTab = { + type: 'terminal', + id: 'term-1::leaf-1', + parentTabId: 'term-1', + leafId: 'leaf-1', + title: 'Claude', + status: 'ready', + terminal: 'pty-1', + isActive: true + } + const seeded: MobileTerminalSessionTab = { + ...base, + launchDraft: 'https://github.com/o/r/issues/12' + } + + expect(mobileSessionTabsEqual([base], [seeded])).toBe(false) + expect(mobileSessionTabsEqual([seeded], [base])).toBe(false) + expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true) + }) + it('treats terminal agent-status changes as session-tab changes', () => { const base: MobileTerminalSessionTab = { type: 'terminal', diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index ffe93ac29dd..07255ffbff7 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -17,6 +17,8 @@ export type MobileTerminalSessionTab = { status?: 'pending-handle' | 'ready' terminal: string | null agentStatus?: AgentStatusEntry | null + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string terminalTheme?: MobileTerminalTheme isActive: boolean } @@ -84,6 +86,9 @@ function mobileSessionTabEqual( a.leafId === b.leafId && a.status === b.status && a.terminal === b.terminal && + // A frame whose only delta is the launch draft appearing or retracting + // still has to reach the chat composer. + a.launchDraft === b.launchDraft && JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) && JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null) ) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 7118d23321b..db285aecb42 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -10,25 +10,37 @@ const clearDraftForSend = vi.fn() const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() +// Mutable stand-ins so the launch-draft wiring below can drive chat resolution +// and transcript state; defaults keep the send-seam tests unchanged. +const viewMode = { isTabChatView: (_tabId: string) => true } +const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } +const draftsArgs: Record[] = [] + // The controller composes many session hooks; each is mocked to a minimal shape // so this test isolates the send seam (outcome -> drafts accounting). vi.mock('./use-mobile-session-view-mode', () => ({ - useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() }) + useMobileSessionViewMode: () => ({ + isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId), + toggleTabChatView: vi.fn() + }) })) vi.mock('./use-mobile-native-chat-session', () => ({ - useMobileNativeChatSession: () => ({ messages: [] }) + useMobileNativeChatSession: () => sessionState })) vi.mock('./use-mobile-native-chat-drafts', () => ({ - useMobileNativeChatDrafts: () => ({ - composerText: '', - setComposerText: vi.fn(), - pending: [], - captureSendOrigin, - clearDraftForSend, - restoreRejectedDraft, - acceptSend, - holdUnconfirmedSend - }) + useMobileNativeChatDrafts: (args: Record) => { + draftsArgs.push(args) + return { + composerText: '', + setComposerText: vi.fn(), + pending: [], + captureSendOrigin, + clearDraftForSend, + restoreRejectedDraft, + acceptSend, + holdUnconfirmedSend + } + } })) vi.mock('./use-mobile-native-chat-prompts', () => ({ useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null }) @@ -213,6 +225,27 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + it('pre-clears the input line for a text-only send but never for an image send', async () => { + // The image path pastes the image behind its OWN leading Ctrl+U and then calls + // this send; a second clear here wipes the image off the input line and the + // agent receives text alone while the echo bubble still shows the thumbnail. + sendWithOutcome.mockResolvedValue('accepted') + + await act(async () => { + await controller!.handleNativeChatSend('answer') + }) + expect(sendWithOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ text: 'answer', clearInputFirst: true }) + ) + + await act(async () => { + await controller!.handleNativeChatSend('look', ['file:///a.jpg']) + }) + expect(sendWithOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ text: 'look', clearInputFirst: false }) + ) + }) + it('holds an unknown-outcome send without posting the optimistic echo', async () => { sendWithOutcome.mockResolvedValue('unknown') let accepted = false @@ -282,3 +315,105 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(onSendError).toHaveBeenCalledWith('Message not sent') }) }) + +describe('useMobileNativeChatController launch-draft wiring', () => { + let renderer: ReactTestRenderer | null = null + const clientStub = { sendRequest: vi.fn() } + + const chatTab = { + type: 'terminal', + id: 'tab-1', + title: 'Claude', + terminal: 'term-1', + launchAgent: 'claude', + launchDraft: 'https://github.com/o/r/issues/12', + isActive: true + } + + function Harness({ tab }: { tab: unknown }): null { + useMobileNativeChatController({ + client: clientStub as unknown as RpcClient, + connState: 'connected', + hostId: 'h', + worktreeId: 'w', + activeSessionTab: tab as never, + activeSessionTabId: 'tab-1', + activeHandleRef: { current: 'term-1' }, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + onSendError: vi.fn(), + onSendResolved: vi.fn() + }) + return null + } + + function render(tab: unknown): void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { + if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { + return + } + original(...a) + }) + try { + act(() => { + renderer = create(createElement(Harness, { tab })) + }) + } finally { + spy.mockRestore() + } + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + draftsArgs.length = 0 + viewMode.isTabChatView = () => true + sessionState.messages = [] + sessionState.status = 'ready' + sessionState.transcriptLoading = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('forwards the tab launch draft and chat-active flag for a chat-resolved tab', () => { + render(chatTab) + + expect(draftsArgs.at(-1)).toMatchObject({ + tabId: 'tab-1', + launchDraft: 'https://github.com/o/r/issues/12', + chatActive: true, + transcriptLoading: false + }) + }) + + it('forwards the raw draft with chatActive false when the tab shows the terminal', () => { + // Nulling the draft off-chat is indistinguishable from a host retraction and + // permanently declines the prefill; the flag is what keeps them apart. + viewMode.isTabChatView = () => false + render(chatTab) + + expect(draftsArgs.at(-1)).toMatchObject({ + launchDraft: 'https://github.com/o/r/issues/12', + chatActive: false + }) + }) + + it('forwards the session hook’s transcriptLoading, not its status', () => { + // 'working' masks 'loading' in status, so only the read-phase signal is honest. + sessionState.status = 'working' + sessionState.transcriptLoading = true + render(chatTab) + + expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true }) + }) + + it('forwards a null draft for a tab that publishes none', () => { + render({ ...chatTab, launchDraft: undefined }) + + expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 8ca4b0b6075..255e28981d9 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -143,7 +143,13 @@ export function useMobileNativeChatController(args: { worktreeId, tabId: activeSessionTabId, sessionId: activeChatSessionId, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + launchDraft: activeSessionTab?.launchDraft ?? null, + // Why: pass the raw draft plus this flag rather than nulling it off-chat — + // a null is indistinguishable from a host retraction, and peeking at the + // terminal view would permanently decline the prefill. + chatActive: showNativeChat, + transcriptLoading: nativeChatSession.transcriptLoading }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts new file mode 100644 index 00000000000..3b830c0384b --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -0,0 +1,310 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +// Adoption and retirement of the host-published launch draft (the TUI-input +// prefill mirrored into the chat composer). Split from the send/pending suite +// so both stay under the per-file line cap. +describe('useMobileNativeChatDrafts launch draft', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [], + launchDraft = null, + chatActive = true, + transcriptLoading = false + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + launchDraft?: string | null + chatActive?: boolean + transcriptLoading?: boolean + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages, + launchDraft, + chatActive, + transcriptLoading + }) + return null + } + + async function mount(tabId: string): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('prefills the composer from a host launch draft exactly once', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' }) + ) + ) + expect(state?.composerText).toBe('https://github.com/o/r/issues/12') + + // A user clear must not see the prefill resurrected on the next render. + act(() => state?.setComposerText('')) + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('does not overwrite typed composer text with a launch draft', async () => { + await mount('a') + act(() => state?.setComposerText('typed first')) + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('typed first') + }) + + it('declines a launch draft when the transcript already has a user turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', 'already sent')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('clears an untouched prefill once a user turn lands, keeping user edits', async () => { + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', 'sent from the TUI')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('') + + // Edited prefill survives resolution on another tab's copy. + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + act(() => state?.setComposerText('issue link plus my notes')) + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'b', + messages: [userTextMessage('m2', 'sent from the TUI')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('issue link plus my notes') + }) + + it('holds the launch draft until the transcript read settles', async () => { + // `session.tabs` carries launchDraft before the transcript loads. Seeding on + // the empty in-flight list would prefill an already-submitted issue link, and + // a send tapped before it retracts duplicates it to the agent. + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true }) + ) + ) + expect(state?.composerText).toBe('') + + // The settled transcript already holds the submitted turn — decline for good. + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + messages: [userTextMessage('m1', 'already sent from the TUI')] + }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('seeds once the transcript settles empty', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true }) + ) + ) + expect(state?.composerText).toBe('') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('holds the seed while the tab is not resolved to chat view', async () => { + // Off chat the session hook is not subscribed, so `messages` is empty for a + // reason that says nothing about the transcript — judging the seed from it + // would prefill an issue link the agent already submitted in the TUI. + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', launchDraft: 'issue link', chatActive: false }) + ) + ) + expect(state?.composerText).toBe('') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + messages: [userTextMessage('m1', 'already sent from the TUI')] + }) + ) + ) + expect(state?.composerText).toBe('') + }) + + it('keeps an adopted prefill when the tab momentarily drops out of the snapshot', async () => { + // A session-tabs frame can transiently omit the active tab: the draft then + // reads as retracted and chat as inactive, but nothing was resolved. + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null, chatActive: false })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('does not decline another tab’s prefill from the transcript it is still showing', async () => { + // The session hook resets its list in an effect, so the commit that first + // sees tab b still carries tab a's turns. Only transcriptLoading says so. + const carriedOver = [userTextMessage('m1', 'sent on a')] + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver })) + ) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'b', + messages: carriedOver, + launchDraft: 'issue link', + transcriptLoading: true + }) + ) + ) + expect(state?.composerText).toBe('') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('does not retire an adopted prefill from the transcript it is still showing', async () => { + const carriedOver = [userTextMessage('m1', 'sent on a')] + await mount('b') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver })) + ) + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'b', + messages: carriedOver, + launchDraft: 'issue link', + transcriptLoading: true + }) + ) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + }) + + it('clears an untouched prefill when the host stops publishing the launch draft', async () => { + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null })) + ) + expect(state?.composerText).toBe('') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 233ae34eb7c..bf3f94bd239 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -43,18 +43,27 @@ describe('useMobileNativeChatDrafts', () => { function Harness({ tabId, sessionId = `session-${tabId}`, - messages = [] + messages = [], + launchDraft = null, + chatActive = true, + transcriptLoading = false }: { tabId: string sessionId?: string | null messages?: NativeChatMessage[] + launchDraft?: string | null + chatActive?: boolean + transcriptLoading?: boolean }): null { state = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'worktree', tabId, sessionId, - messages + messages, + launchDraft, + chatActive, + transcriptLoading }) return null } diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a72a08f68ad..79b82ad5cc6 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -41,6 +41,15 @@ export function useMobileNativeChatDrafts(args: { tabId: string | null sessionId: string | null messages: readonly NativeChatMessage[] + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string | null + /** Whether the tab is currently resolved to the chat view. Off-chat the + * launch-draft effects hold their state instead of acting on it. */ + chatActive?: boolean + /** `messages` is not yet this session's real history (read in flight, or the + * transcript still belongs to the previously active tab), so it cannot be + * trusted to decline or retire the seed. */ + transcriptLoading?: boolean }): { composerText: string setComposerText: Dispatch> @@ -57,7 +66,16 @@ export function useMobileNativeChatDrafts(args: { onUnconfirmed: () => void ) => void } { - const { hostId, worktreeId, tabId, sessionId, messages } = args + const { + hostId, + worktreeId, + tabId, + sessionId, + messages, + launchDraft, + chatActive = true, + transcriptLoading + } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null const [drafts, setDrafts] = useState>({}) @@ -73,6 +91,63 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) + // Seeded launch-context text per tab; '' marks a permanent decline so a + // cleared composer never resurrects the prefill. + const seededLaunchDraftByKeyRef = useRef(new Map()) + + // Why: launch context delivered as a TUI-input prefill is invisible in chat; + // adopt it once as the composer draft so mobile shows the same context. + useEffect(() => { + if ( + !draftKey || + !chatActive || + !launchDraft?.trim() || + seededLaunchDraftByKeyRef.current.has(draftKey) + ) { + return + } + // Why: `session.tabs` carries launchDraft before the transcript read settles, + // and an empty (or previous tab's) list would let the decline below misjudge + // an already-submitted prefill — long enough for a send to duplicate it. + if (transcriptLoading) { + return + } + // A user turn already in the transcript means the one-line TUI prefill was + // submitted or deliberately cleared; decline instead of resurrecting it. + if (messages.some((message) => normalizedUserText(message) !== null)) { + seededLaunchDraftByKeyRef.current.set(draftKey, '') + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) + setDrafts((previous) => + (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + + // Drop an untouched adopted copy once the prefill is resolved elsewhere — a + // user turn landed (sent or cleared TUI-side) or the host stopped publishing + // it (desktop sent or reconciled it). User edits are always kept. + useEffect(() => { + // Same gates as the seed: off-chat there is no retraction to read (the tab + // publishes no draft to us), and an untrusted transcript would wipe an + // untouched copy on the strength of another tab's user turns. + if (!draftKey || !chatActive || transcriptLoading) { + return + } + const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) + if (!seeded) { + return + } + const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + if (!hasUserTurn && launchDraft?.trim()) { + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, '') + setDrafts((previous) => + (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + const setComposerText: Dispatch> = useCallback( (value) => { if (!draftKey) { diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 559524f3c5d..366dd426570 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -99,6 +99,13 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + // Why: pre-clear only when nothing was deliberately pasted first. The heal + // above fires only for terminals a mobile image paste marked, so a desktop + // launch-draft prefill parked on the input line would otherwise glue onto + // this message. An image send already led its own paste with Ctrl+U, and a + // second one here would wipe the image it just pasted (desktop's image path + // likewise clears once, before the paste, and never again). + clearInputFirst: !images?.length, deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 584f34624f2..3c12630690b 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -218,3 +218,143 @@ describe('useMobileNativeChatSession', () => { expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) }) }) + +describe('useMobileNativeChatSession transcriptLoading', () => { + let renderer: ReactTestRenderer | null = null + const renders: { + sessionId: string | null + transcriptLoading: boolean + status: string + ids: string[] + }[] = [] + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + renders.length = 0 + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ + client, + sessionId, + agent = 'claude' + }: { + client: RpcClient | null + sessionId: string | null + agent?: string | null + }): null { + const session = useMobileNativeChatSession({ + client, + agent, + sessionId, + transcriptPath: null + }) + renders.push({ + sessionId, + transcriptLoading: session.transcriptLoading, + status: session.status, + ids: session.messages.map((entry) => entry.id) + }) + return null + } + + async function mountAt(client: RpcClient | null, sessionId: string | null): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client, sessionId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('reports loading on the very first render, before the subscription effect runs', async () => { + // `status` starts at 'idle', so on its own it would tell the launch-draft + // seed that an empty transcript is this session's real history. + const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {}) + await mountAt({ subscribe } as unknown as RpcClient, 'session-a') + + expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] }) + }) + + it('re-reads instead of resurfacing a settled read when the same identity returns', async () => { + // Leaving chat view nulls the agent, then returning restores the identity a + // settled read already matched — but its list was cleared, so trusting it + // would report 'ready' over an empty transcript. + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + const client = { subscribe } as unknown as RpcClient + await mountAt(client, 'session-a') + expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false }) + + // Toggle out to the terminal view, then back. + await act(async () => + renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null })) + ) + renders.length = 0 + await act(async () => + renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + }) + + it('re-reads instead of resurfacing a settled read after a reconnect', async () => { + // A reconnect swaps the client without moving the identity; the effect + // re-subscribes and clears the list, so the old outcome must not stand. + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + return () => {} + }) + const client = { subscribe } as unknown as RpcClient + await mountAt(client, 'session-a') + expect(renders.at(-1)).toMatchObject({ status: 'ready' }) + + const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + }) + + it('never hands out the previous session’s messages under the new session id', async () => { + const subscribe: RpcClient['subscribe'] = vi.fn((_method, params, onData) => { + if ((params as { sessionId: string }).sessionId === 'session-a') { + onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) + } + return () => {} + }) + const client = { subscribe } as unknown as RpcClient + await mountAt(client, 'session-a') + await act(async () => + renderer?.update(createElement(Harness, { client, sessionId: 'session-b' })) + ) + + // The effect that resets the list lands a commit later, so `messages` still + // holds session-a's transcript here — it must never surface under b, and b + // must read as loading until its own read settles. + const leaked = renders.find( + (entry) => entry.sessionId === 'session-b' && entry.ids.includes('a-1') + ) + expect(leaked).toBeUndefined() + expect(renders.find((entry) => entry.sessionId === 'session-b')).toMatchObject({ + transcriptLoading: true, + ids: [] + }) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 2d381fdd602..9c739ce49ef 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -13,6 +13,12 @@ export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'r export type MobileNativeChatSession = { messages: NativeChatMessage[] status: MobileNativeChatStatus + /** True while `messages` cannot be trusted as this session's real history: + * the read is in flight, OR the subscription effect has not yet caught up to + * a just-changed agent/session, so `messages`/`status` still describe the + * previous tab. Consumers that decide something from an empty transcript + * (the launch-draft seed) must wait for this to clear. */ + transcriptLoading: boolean error?: string /** True when an older page may exist (the last read filled the window). */ hasMore: boolean @@ -22,6 +28,9 @@ export type MobileNativeChatSession = { loadEarlier: () => void } +// Stable empty reference so a not-yet-current read doesn't churn consumers. +const EMPTY_MESSAGES: NativeChatMessage[] = [] + // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 const PAGE = 60 @@ -42,7 +51,33 @@ export function useMobileNativeChatSession(args: { }): MobileNativeChatSession { const { client, agent, sessionId, transcriptPath } = args const [messages, setMessages] = useState([]) - const [status, setStatus] = useState('idle') + const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}` + // Pre-read status is a pure function of the props, so derive it rather than + // letting the effect write it a commit later. + const initialStatus: MobileNativeChatStatus = + !client || !agent ? 'idle' : !sessionId ? 'waiting-session' : 'loading' + // Only the settled outcome is genuinely async, and it is tagged with the + // identity it describes so a just-switched tab is never judged by the + // previous tab's transcript — the effect that clears `messages` is passive + // and lands a commit late. + const [read, setRead] = useState<{ + client: RpcClient + identity: string + status: MobileNativeChatStatus + } | null>(null) + // Drop it the moment its subscription stops being the live one — identity and + // client are the effect's only inputs, so together they catch every re-run. + // Without this a toggle out of chat view and back (agent null, then the same + // identity again) would resurface a settled 'ready' over an emptied list. + let current = read + if (current !== null && (current.identity !== identity || current.client !== client)) { + current = null + setRead(null) + } + // A settled read only counts while the props still call for one: losing the + // client/agent/session means idle or waiting-session outranks it outright. + const settled = initialStatus === 'loading' ? current : null + const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) @@ -78,16 +113,12 @@ export function useMobileNativeChatSession(args: { setHasMore(false) beforeOffsetRef.current = null if (!client || !agent) { - setStatus('idle') return } if (!sessionId) { - setStatus('waiting-session') return } - setStatus('loading') - const unsubscribe = client.subscribe( 'nativeChat.subscribe', { @@ -121,7 +152,7 @@ export function useMobileNativeChatSession(args: { return } if (applied.kind === 'error') { - setStatus('error') + setRead({ client, identity, status: 'error' }) setError(applied.error) return } @@ -140,7 +171,7 @@ export function useMobileNativeChatSession(args: { setLoadingEarlier(false) beforeOffsetRef.current = null } - setStatus('ready') + setRead({ client, identity, status: 'ready' }) } ) @@ -148,7 +179,7 @@ export function useMobileNativeChatSession(args: { cancelled = true unsubscribe() } - }, [client, agent, sessionId, transcriptPath, setList]) + }, [client, agent, sessionId, transcriptPath, identity, setList]) const loadEarlier = useCallback(() => { if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { @@ -215,5 +246,15 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) - return { messages, status, error, hasMore, loadingEarlier, loadEarlier } + return { + // Withheld until the settled read belongs to this identity: the effect that + // clears the previous tab's list is passive, so `messages` lags a commit. + messages: settled ? messages : EMPTY_MESSAGES, + status, + transcriptLoading: status === 'loading', + error, + hasMore, + loadingEarlier, + loadEarlier + } } diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts index 4d9166c39af..94e39794616 100644 --- a/mobile/src/session/use-pr-bot-author-overrides.ts +++ b/mobile/src/session/use-pr-bot-author-overrides.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { ConnectionState } from '../transport/types' -import type { RpcSuccess } from '../transport/types' +import type { ConnectionState, RpcSuccess } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides' diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index ae6052c9d5b..3870ed244fe 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -13,6 +13,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ 'opencode', 'mimo-code', 'ante', + 'trae', 'pi', 'omp', 'gemini', @@ -50,6 +51,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record = { opencode: 'OpenCode', 'mimo-code': 'MiMo Code', ante: 'Ante', + trae: 'Trae', pi: 'Pi', omp: 'OMP', gemini: 'Gemini', @@ -84,6 +86,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> opencode: 'opencode.ai', 'mimo-code': 'mimo.xiaomi.com', ante: 'antigma.ai', + trae: 'www.trae.cn', omp: 'omp.sh', gemini: 'gemini.google.com', antigravity: 'antigravity.google', diff --git a/package.json b/package.json index 8c6fe214781..5190a892ebd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.160-rc.2", + "version": "1.4.160-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,10 +11,10 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", - "audit:code-quality:native": "oxlint --config config/oxlint-code-quality.json --report-unused-disable-directives-severity warn src config tests mobile", - "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests mobile", + "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", + "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", "audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none", "check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs", "check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs", @@ -22,12 +22,9 @@ "doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", - "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", - "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", - "check:quadratic-buffer-concat": "node config/scripts/check-quadratic-buffer-concat.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", @@ -195,6 +192,7 @@ "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", + "emojibase-data": "17.0.0", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1a656bd12..64dd0cb3440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) + emojibase-data: + specifier: 17.0.0 + version: 17.0.0(emojibase@17.0.0) happy-dom: specifier: ^20.9.0 version: 20.9.0 @@ -3986,6 +3989,15 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emojibase-data@17.0.0: + resolution: {integrity: sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==} + peerDependencies: + emojibase: '*' + + emojibase@17.0.0: + resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==} + engines: {node: '>=18.12.0'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -10031,6 +10043,12 @@ snapshots: emoji-regex@8.0.0: {} + emojibase-data@17.0.0(emojibase@17.0.0): + dependencies: + emojibase: 17.0.0 + + emojibase@17.0.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index c1ac7d5d8f7..ed11474d97e 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -60,22 +60,29 @@ If a command returns `orchestration_migration_required`, `run_required`, or a li 1. Confirm `effectsApplied` is `false`. 2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`. 3. Read the guide completely. Do not retry the rejected command unchanged. -4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`. -5. Inspect any pre-upgrade terminal before creating replacement work. +4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement. +5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point. +6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement. The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. -Pre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection: +The cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects. + +Legacy database rows and terminal output remain available for explicit read-only inspection: ```bash orca orchestration run-list --json orca orchestration run-show --id run_legacy_local --json orca orchestration task-list --run run_legacy_local --json orca orchestration inbox --full --json -orca orchestration check --terminal --peek --json +orca orchestration check --terminal --peek --format --json +orca terminal read --terminal --json +orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json ``` -Read-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run. +Read-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands. + +Never launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run. ## Ownership diff --git a/src/cli/args.ts b/src/cli/args.ts index 90a42ac813b..3d37517e795 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,5 +1,9 @@ import { RuntimeClientError } from './runtime/types' import { unknownCommandData, unknownFlagData } from './command-suggestion' +import { specPaths, type CommandSpec } from './command-spec' + +export { specPaths } +export type { CommandSpec } export type ParsedArgs = { commandPath: string[] @@ -7,23 +11,6 @@ export type ParsedArgs = { positionalFlagConflicts?: string[] } -export type CommandSpec = { - path: string[] - // Why: conventional alternate verbs should resolve without duplicating specs - // or handler registrations. - aliases?: string[][] - argumentMode?: 'parsed' | 'passthrough' - // Why: irreversibly destroys persistent state — typo recovery must not steer a - // benign mistake into one of these via the agent nextSteps channel. #6303 - destructive?: boolean - summary: string - usage: string - allowedFlags: string[] - positionalArgs?: string[] - examples?: string[] - notes?: string[] -} - export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment'] const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment']) export const BOOLEAN_FLAGS = new Set([ @@ -157,12 +144,6 @@ export function matches(actual: string[], expected: string[]): boolean { ) } -// Why: a spec is reachable by its canonical path plus any declared aliases — one -// definition so resolution, validation, help, and agent-context never disagree. -export function specPaths(spec: CommandSpec): string[][] { - return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] -} - export function supportsBrowserPageFlag(commandPath: string[]): boolean { const joined = commandPath.join(' ') if (['open', 'status'].includes(commandPath[0])) { diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 83ca43d4859..f622f9d79b1 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -293,7 +293,7 @@ describe('orca cli browser page targeting', () => { }) }) -describe('orca cli browser tab profiles', () => { +describe('orca cli browser profile management', () => { beforeEach(() => { callMock.mockReset() }) diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 8b6e0d41847..93832dea9fe 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.\n\nIf a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:\n\n1. Confirm `effectsApplied` is `false`.\n2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.\n3. Read the guide completely. Do not retry the rejected command unchanged.\n4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`.\n5. Inspect any pre-upgrade terminal before creating replacement work.\n\nThe arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.\n\nPre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection:\n\n```bash\norca orchestration run-list --json\norca orchestration run-show --id run_legacy_local --json\norca orchestration task-list --run run_legacy_local --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --json\n```\n\nRead-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.\n\nIf a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:\n\n1. Confirm `effectsApplied` is `false`.\n2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.\n3. Read the guide completely. Do not retry the rejected command unchanged.\n4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement.\n5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point.\n6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement.\n\nThe arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.\n\nThe cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects.\n\nLegacy database rows and terminal output remain available for explicit read-only inspection:\n\n```bash\norca orchestration run-list --json\norca orchestration run-show --id run_legacy_local --json\norca orchestration task-list --run run_legacy_local --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nRead-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands.\n\nNever launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/command-spec.ts b/src/cli/command-spec.ts new file mode 100644 index 00000000000..1719fbd96fe --- /dev/null +++ b/src/cli/command-spec.ts @@ -0,0 +1,18 @@ +export type CommandSpec = { + path: string[] + // Why: conventional alternate verbs should resolve without duplicating specs or handlers. + aliases?: string[][] + argumentMode?: 'parsed' | 'passthrough' + // Why: typo recovery must never steer a benign mistake into destructive state changes. + destructive?: boolean + summary: string + usage: string + allowedFlags: string[] + positionalArgs?: string[] + examples?: string[] + notes?: string[] +} + +export function specPaths(spec: CommandSpec): string[][] { + return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] +} diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index 530d9baf78a..db481de6ea8 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -1,5 +1,4 @@ -import type { CommandSpec } from './args' -import { specPaths } from './args' +import { specPaths, type CommandSpec } from './command-spec' // Why: rank the live registry so typo recovery cannot drift from accepted paths. diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2b83f5c15ea..03f9c24c9f3 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -4,7 +4,12 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { CommandHandler } from '../dispatch' import { printResult } from '../format' -import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from '../runtime-client' +import { + RuntimeClientError, + type RuntimeClient, + type RuntimeRpcSuccess, + getDefaultUserDataPath +} from '../runtime-client' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/types' @@ -12,7 +17,6 @@ import { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } from '../../main/agent-hooks/managed-agent-hook-controls' -import { getDefaultUserDataPath } from '../runtime-client' type AgentHookCommandResult = { enabled: boolean diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 08530ea29c3..7827f91347d 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -1,8 +1,7 @@ import type { CommandHandler } from '../dispatch' import { formatEnvironment, formatEnvironmentList, printResult } from '../format' -import { getDefaultUserDataPath } from '../runtime-client' +import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client' import type { RuntimeRpcSuccess } from '../runtime-client' -import { RuntimeClientError } from '../runtime-client' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import { addEnvironmentFromPairingCode, diff --git a/src/cli/handlers/orchestration-check-identity.test.ts b/src/cli/handlers/orchestration-check-identity.test.ts new file mode 100644 index 00000000000..7c4908dc782 --- /dev/null +++ b/src/cli/handlers/orchestration-check-identity.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.hoisted(() => vi.fn()) +const getTerminalHandleMock = vi.hoisted(() => vi.fn()) +const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE +const originalPaneKey = process.env.ORCA_PANE_KEY + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('orchestration check identity', () => { + beforeEach(() => { + callMock.mockReset().mockResolvedValue({ result: { messages: [], count: 0 } }) + getTerminalHandleMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE + delete process.env.ORCA_PANE_KEY + }) + + afterEach(() => { + if (originalTerminalHandle === undefined) { + delete process.env.ORCA_TERMINAL_HANDLE + } else { + process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle + } + if (originalPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = originalPaneKey + } + }) + + const invokeCheck = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration check']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it('carries the caller pane key when the environment handle may be stale', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord' + process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord' + getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe')) + + await invokeCheck(new Map([['wait', true]])) + + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).toHaveBeenCalledWith( + 'orchestration.check', + expect.objectContaining({ + terminal: 'term_stale_coord', + terminalPaneKey: 'tab_coord:leaf_coord', + wait: true + }) + ) + }) + + it('keeps an explicit legacy terminal handle scoped to that handle', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_stale_env' + + await invokeCheck(new Map([['terminal', 'term_legacy_worker']])) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.check', + expect.objectContaining({ + terminal: 'term_legacy_worker', + terminalPaneKey: undefined + }) + ) + }) +}) diff --git a/src/cli/handlers/orchestration-legacy-read-only.test.ts b/src/cli/handlers/orchestration-legacy-read-only.test.ts new file mode 100644 index 00000000000..2a4037fa7a9 --- /dev/null +++ b/src/cli/handlers/orchestration-legacy-read-only.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +beforeEach(() => { + callMock.mockReset() + vi.mocked(printResult).mockReset() +}) + +describe('legacy orchestration CLI inspection', () => { + it('labels legacy rows in plain check output', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status' + } + ], + count: 1 + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + expect(formatter?.(result)).toContain('msg_legacy [legacy, read-only]') + }) + + it('rebuilds legacy formatted output without runtime-supplied actions', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status', + body: 'Tests are running.', + payload: '{"phase":"testing"}' + } + ], + count: 1, + formatted: '[Reply: orca orchestration reply --id msg_legacy --from term_coord --body "..."]' + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + const output = formatter?.(result) + expect(output).toContain('msg_legacy [legacy, read-only]') + expect(output).toContain('Tests are running.') + expect(output).toContain('[payload] {"phase":"testing"}') + expect(output).not.toContain('[Reply:') + expect(output).not.toContain('orchestration reply') + }) + + it('preserves runtime formatting when every message belongs to a current Run', async () => { + const result = { + messages: [ + { + id: 'msg_current', + run_id: 'run_current', + from_handle: 'term_worker', + subject: 'question' + } + ], + count: 1, + formatted: '[Reply: current Run action]' + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + expect(formatter?.(result)).toBe(result.formatted) + }) + + it('labels legacy rows in full inbox output without hiding their body', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + to_handle: 'term_coord', + subject: 'progress', + body: 'Tests are running.' + } + ], + count: 1 + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration inbox']({ + flags: new Map([['full', true]]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + const output = formatter?.(result) + expect(output).toContain('msg_legacy [legacy, read-only]') + expect(output).toContain('Tests are running.') + }) +}) diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index 09b4a7a34b9..95a0a7db313 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -7,10 +7,12 @@ vi.mock('../format', () => ({ printResult: vi.fn() })) vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) import { ORCHESTRATION_HANDLERS } from './orchestration' +import { printResult } from '../format' describe('orchestration worker-start CLI contract', () => { beforeEach(() => { callMock.mockReset() + vi.mocked(printResult).mockReset() process.exitCode = undefined }) @@ -104,6 +106,47 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBe(1) }) + it('prints a reveal warning for a live background worker', async () => { + callMock.mockResolvedValue({ + result: { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + warning: 'Terminal term_worker is running but could not be revealed.', + effects: [], + residualResources: [] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags: new Map([ + ['task', 'task_1'], + ['agent', 'codex'], + ['from', 'term_coord'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as + | ((result: { + taskId: string + dispatchId: string + state: string + warning?: string + }) => string) + | undefined + expect( + formatter?.({ + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + warning: 'Terminal term_worker is running but could not be revealed.' + }) + ).toContain('Warning: Terminal term_worker is running but could not be revealed.') + }) + it('allows the initial zero cursor when paging worker output', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index d5399ece4aa..f064a645394 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -686,6 +686,7 @@ describe('orchestration timeout flag validation', () => { // the non-consuming all mode instead of the destructive mark-read default. expect(callMock).toHaveBeenCalledWith('orchestration.check', { terminal: 'term_worker', + terminalPaneKey: undefined, unread: false, peek: true, all: undefined, diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 220f0964251..e1c6d2d4af4 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -22,6 +22,7 @@ import type { import type { NativeChatMessage } from '../../shared/native-chat-types' import type { RuntimeTerminalRead } from '../../shared/runtime-types' import { + ORCHESTRATION_LEGACY_RUN_ID, orchestrationMigrationData, orchestrationSkillRecoveryData } from '../../shared/orchestration-rpc-contract' @@ -76,6 +77,7 @@ const TASK_STATUS_VALUES = [ type MessageSummary = { id: string + run_id?: string from_handle: string to_handle?: string subject: string @@ -85,6 +87,31 @@ type MessageSummary = { read?: number } +function formatMessageReadOnlyTag(message: MessageSummary): string { + return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : '' +} + +function isLegacyReadOnlyMessage(message: MessageSummary): boolean { + return message.run_id === ORCHESTRATION_LEGACY_RUN_ID +} + +function formatLegacyAwareCheckMessages(messages: MessageSummary[]): string { + return messages + .map((message) => { + const lines = [ + `${message.id}${formatMessageReadOnlyTag(message)} [${message.type ?? 'status'}] from=${message.from_handle} "${message.subject}"` + ] + if (message.body) { + lines.push(message.body) + } + if (message.payload) { + lines.push(`[payload] ${message.payload}`) + } + return lines.join('\n') + }) + .join('\n\n') +} + type LifecycleSendRejection = { action: 'rejected' code: string @@ -555,6 +582,7 @@ export const ORCHESTRATION_HANDLERS: Record = { ) } const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const explicitTerminal = getOptionalStringFlag(flags, 'terminal') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') // Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4. @@ -573,6 +601,7 @@ export const ORCHESTRATION_HANDLERS: Record = { try { result = await callMutation(client, flags, 'orchestration.check', { terminal, + terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read. unread: flags.has('unread') ? true : peek ? false : undefined, peek: peek ? true : undefined, @@ -617,7 +646,9 @@ export const ORCHESTRATION_HANDLERS: Record = { } printResult(result, json, (r) => { if (r.formatted) { - return r.formatted + return r.messages.some(isLegacyReadOnlyMessage) + ? formatLegacyAwareCheckMessages(r.messages) + : r.formatted } if (r.count === 0) { if (r.timedOut) { @@ -631,7 +662,10 @@ export const ORCHESTRATION_HANDLERS: Record = { return 'No messages.' } const rendered = r.messages - .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) + .map( + (m) => + `${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"` + ) .join('\n') return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered }) @@ -669,7 +703,7 @@ export const ORCHESTRATION_HANDLERS: Record = { // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. return r.messages .map((m) => { - const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` + const head = `${m.id}${formatMessageReadOnlyTag(m)} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` if (!full) { return head } @@ -790,6 +824,7 @@ export const ORCHESTRATION_HANDLERS: Record = { state: string failedStage?: string lastError?: string + warning?: string effects: unknown[] residualResources: unknown[] }>(client, flags, 'orchestration.workerStart', { @@ -815,9 +850,10 @@ export const ORCHESTRATION_HANDLERS: Record = { } printResult(result, json, (worker) => { const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` - return worker.lastError - ? `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` - : base + if (worker.lastError) { + return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` + } + return worker.warning ? `${base}\nWarning: ${worker.warning}` : base }) }, diff --git a/src/cli/handlers/vm.ts b/src/cli/handlers/vm.ts index aaf01b08872..e000ec15afe 100644 --- a/src/cli/handlers/vm.ts +++ b/src/cli/handlers/vm.ts @@ -5,11 +5,13 @@ import { RuntimeClientError } from '../runtime-client' import { parseOrcaYaml } from '../../shared/orca-yaml' import { getEphemeralVmRecipeResultProjectRoot, - getEphemeralVmRecipeResultWarnings, - redactEphemeralVmRecipeDiagnosticText, type EphemeralVmRecipeDoctorCheck, type EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' +import { + getEphemeralVmRecipeResultWarnings, + redactEphemeralVmRecipeDiagnosticText +} from '../../shared/ephemeral-vm-recipe-diagnostics' // Why: import directly from the doctor module (not the barrel) — it uses Node // fs/path and must stay out of the browser bundle that imports the barrel. import { doctorEphemeralVmRecipe } from '../../shared/ephemeral-vm-recipe-doctor' diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index fc6c3339ded..b88f3cf3227 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -88,7 +88,7 @@ export async function sendRequest( ) }) }) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk // Why: the server may interleave `{"_keepalive":true}\n` frames with the // final success/failure frame to keep both idle timers alive during a diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 3acca6a64ef..89124fe86a8 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -7,33 +7,20 @@ vi.mock('electron', () => ({ } })) -import { CodexHookService } from '../codex/hook-service' -import { DroidHookService } from '../droid/hook-service' -import { CursorHookService } from '../cursor/hook-service' -import { CommandCodeHookService } from '../command-code/hook-service' -import { GeminiHookService } from '../gemini/hook-service' -import { AntigravityHookService } from '../antigravity/hook-service' -import { AmpHookService } from '../amp/hook-service' -import { ClaudeHookService } from '../claude/hook-service' -import { GrokHookService } from '../grok/hook-service' -import { CopilotHookService } from '../copilot/hook-service' -import { HermesHookService } from '../hermes/hook-service' -import { DevinHookService } from '../devin/hook-service' -import { KimiHookService } from '../kimi/hook-service' +import { CodexHookService, codexHookService } from '../codex/hook-service' +import { DroidHookService, droidHookService } from '../droid/hook-service' +import { CursorHookService, cursorHookService } from '../cursor/hook-service' +import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service' +import { GeminiHookService, geminiHookService } from '../gemini/hook-service' +import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service' +import { AmpHookService, ampHookService } from '../amp/hook-service' +import { ClaudeHookService, claudeHookService } from '../claude/hook-service' +import { GrokHookService, grokHookService } from '../grok/hook-service' +import { CopilotHookService, copilotHookService } from '../copilot/hook-service' +import { HermesHookService, hermesHookService } from '../hermes/hook-service' +import { DevinHookService, devinHookService } from '../devin/hook-service' +import { KimiHookService, kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' -import { ampHookService } from '../amp/hook-service' -import { antigravityHookService } from '../antigravity/hook-service' -import { claudeHookService } from '../claude/hook-service' -import { codexHookService } from '../codex/hook-service' -import { copilotHookService } from '../copilot/hook-service' -import { cursorHookService } from '../cursor/hook-service' -import { droidHookService } from '../droid/hook-service' -import { commandCodeHookService } from '../command-code/hook-service' -import { geminiHookService } from '../gemini/hook-service' -import { devinHookService } from '../devin/hook-service' -import { grokHookService } from '../grok/hook-service' -import { hermesHookService } from '../hermes/hook-service' -import { kimiHookService } from '../kimi/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { installRemoteManagedAgentHooks, diff --git a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts new file mode 100644 index 00000000000..dd771d8b2be --- /dev/null +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AgentHookServer } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' +const SECOND_CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f3170' + +function line(record: unknown): string { + return `${JSON.stringify(record)}\n` +} + +function spawnLine(threadId: string, agentPath: string): string { + return line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: threadId, + agent_path: agentPath, + kind: 'started' + } + }) +} + +describe('AgentHookServer Codex subagent transcript polling', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('publishes rollout-only children and removes them after their task completes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: CHILD_ID, + agent_path: '/root/pr_review', + kind: 'started' + } + }) + ) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: CHILD_ID, description: '/root/pr_review' }) + ]) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 2_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + // Why: the poll re-arms off the object it just stored; if that identity ever drifts it stops after the first change. + it('keeps polling across successive roster changes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + const secondChildPath = join(dir, `rollout-child-${SECOND_CHILD_ID}.jsonl`) + const started = line({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(childPath, started) + writeFileSync(secondChildPath, started) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) + + appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/perf_audit')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(2) + }, + { timeout: 3_000, interval: 50 } + ) + + const complete = line({ type: 'event_msg', payload: { type: 'task_complete' } }) + appendFileSync(childPath, complete) + appendFileSync(secondChildPath, complete) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + // Why: a nested non-codex CLI inherits the pane's ORCA_PANE_KEY, so its hook must not tear down the codex poll. + it('keeps polling when a nested non-codex hook lands on the same pane', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const post = (path: string, payload: unknown): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', payload }) + }) + + await post('/hook/codex', { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + }) + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) + + const nested = await post('/hook/copilot', { + hook_event_name: 'Stop', + session_id: 'nested-session', + transcript_path: join(dir, 'nested-copilot-transcript.jsonl') + }) + expect(nested.status).toBe(204) + // The nested completion is suppressed, so the pane is still the same live codex turn. + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index a9395cf44de..8887a285235 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -15,6 +15,7 @@ import { clearClaudeAnsweredQuestionWait, createHookListenerState, getEndpointFileName, + hasCodexTranscriptSubagents, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, markClaudeLeadTurnInterrupted, @@ -106,6 +107,7 @@ type PaneKeyAliasEntry = { const LAST_STATUS_FILE_NAME = 'last-status.json' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 +const CODEX_SUBAGENT_POLL_MS = 1_000 const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 // Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). @@ -484,6 +486,7 @@ export class AgentHookServer { // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. private statusPersistTimer: ReturnType | null = null private assistantMessageRetryTimers = new Map>() + private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() @@ -1040,6 +1043,49 @@ export class AgentHookServer { this.assistantMessageRetryTimers.delete(paneKey) } + private clearCodexSubagentPoll(paneKey: string): void { + const timer = this.codexSubagentPollTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.codexSubagentPollTimers.delete(paneKey) + } + + private scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.clearCodexSubagentPoll(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + const timer = setTimeout(() => { + this.codexSubagentPollTimers.delete(original.paneKey) + const current = this.state.lastStatusByPaneKey.get(original.paneKey) + if (!this.server || current !== original) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { + return + } + const subagentsChanged = + JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + this.scheduleCodexSubagentPoll(source, body, next) + }, CODEX_SUBAGENT_POLL_MS) + this.codexSubagentPollTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + private scheduleAssistantMessageRetry( source: AgentHookSource, body: unknown, @@ -1264,6 +1310,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) } this.clearAssistantMessageRetry(previousOwnerPaneKey) + this.clearCodexSubagentPoll(previousOwnerPaneKey) // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, @@ -1296,6 +1343,7 @@ export class AgentHookServer { for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) this.clearAssistantMessageRetry(key) + this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) this.runtimeObservedStatusPaneKeys.delete(key) this.promptSentDedupeByPaneKey.delete(key) @@ -1584,7 +1632,7 @@ export class AgentHookServer { if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() } - this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { res.writeHead(404) res.end() @@ -1627,6 +1675,7 @@ export class AgentHookServer { if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) { const enriched = this.applyNormalizedStatus(normalized) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) } res.writeHead(204) @@ -1636,6 +1685,10 @@ export class AgentHookServer { res.writeHead(204) res.end() } + } + // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + this.server = createServer((req, res) => { + void handleRequest(req, res) }) await new Promise((resolve, reject) => { @@ -1675,6 +1728,10 @@ export class AgentHookServer { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() + for (const timer of this.codexSubagentPollTimers.values()) { + clearTimeout(timer) + } + this.codexSubagentPollTimers.clear() // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. this.endpointDir = null this.endpointFilePathCache = null @@ -1751,6 +1808,7 @@ export class AgentHookServer { } this.state.lastStatusByPaneKey.delete(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) if (existing.payload.state === 'done') { this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) @@ -1816,6 +1874,7 @@ export class AgentHookServer { statusChanged = true } this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.runtimeObservedStatusPaneKeys.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) @@ -1834,6 +1893,7 @@ export class AgentHookServer { // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) let clearedAlias = false diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index d690ea10d8a..1c77130c528 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -135,6 +135,28 @@ describe('runtime AI Vault session scanner', () => { ) }) + it('keeps a repinned account home instead of stripping it', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: { + useRealCodexHome: false, + substituteCodexHome: '/data/orca/codex-accounts/account-2/home' + } + }) + + await expect( + prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', { + agent: 'codex', + filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl', + codexHome: '/managed', + executionHostId: 'runtime:env-1' + }) + ).resolves.toEqual({ + useRealCodexHome: false, + substituteCodexHome: '/data/orca/codex-accounts/account-2/home' + }) + }) + it('fails retryably when runtime preparation returns an invalid result', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} }) diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 48c4027c4bb..baf4ca6d322 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -105,7 +105,12 @@ const aiVaultListResultSchema = z.object({ scannedAt: z.string() }) -const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() }) +// Why: zod strips unknown keys, so the repin home must be declared or the +// parent would silently drop it and resume under the wrong account's home. +const aiVaultPrepareSessionResumeResultSchema = z.object({ + useRealCodexHome: z.boolean(), + substituteCodexHome: z.string().optional() +}) export function getSavedRuntimeAiVaultHostInfos( userDataPath: string diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index b89b97b229e..5d6c6202824 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' -import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' -import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { + buildOpenCodeSqliteCandidatePath, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' import type { SessionFileCandidate } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' diff --git a/src/main/ai-vault/session-scanner-record-value.ts b/src/main/ai-vault/session-scanner-record-value.ts new file mode 100644 index 00000000000..bd2c9b6326d --- /dev/null +++ b/src/main/ai-vault/session-scanner-record-value.ts @@ -0,0 +1,5 @@ +export function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} diff --git a/src/main/ai-vault/session-scanner-token-values.ts b/src/main/ai-vault/session-scanner-token-values.ts index e2621b9f491..0c30558ff75 100644 --- a/src/main/ai-vault/session-scanner-token-values.ts +++ b/src/main/ai-vault/session-scanner-token-values.ts @@ -1,5 +1,5 @@ import type { CodexUsageSnapshot } from './session-scanner-types' -import { asRecord } from './session-scanner-values' +import { asRecord } from './session-scanner-record-value' export function tokenTotal(value: unknown): number { const usage = asRecord(value) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index f9af3f20897..ac94a067d2a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -1,5 +1,5 @@ -import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { + AiVaultAgent, AiVaultScanIssue, AiVaultSession, AiVaultSessionPreviewMessage diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 39006bd68a4..1deb8bf5dc9 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,6 +1,9 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { readFile } from 'node:fs/promises' +import { asRecord } from './session-scanner-record-value' + +export { asRecord } export function timestampMs(value: unknown): number { if (typeof value === 'string') { @@ -25,12 +28,6 @@ export function parseJsonObject(line: string): Record | null { } } -export function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null -} - export function extractString(value: unknown): string | null { if (typeof value !== 'string') { return null diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index dc804a2dbe1..b9f5a9bd92d 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -63,6 +63,8 @@ import { CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR } from '../../shared/clipboard-text' +type ExecFileCallback = (error: unknown, stdout?: string, stderr?: string) => void + // Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId // inside a try/catch. Override the private method to inject our mock. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -116,31 +118,37 @@ function mockWebContents(id: number, url = 'https://example.com', title = 'Examp } function succeedWith(data: unknown): void { - execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => { - cb(null, JSON.stringify({ success: true, data }), '') - return { - stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + execFileMock.mockImplementation( + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + cb(null, JSON.stringify({ success: true, data }), '') + return { + stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + } } - }) + ) } function succeedForContentEditable(data: unknown = { ok: true }): void { - execFileMock.mockImplementation((_bin: string, args: string[], _opts: unknown, cb: Function) => { - const result = - args.includes('get') && args.includes('attr') && args.includes('contenteditable') - ? { value: 'true' } - : data - cb(null, JSON.stringify({ success: true, data: result }), '') - return { - stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + const result = + args.includes('get') && args.includes('attr') && args.includes('contenteditable') + ? { value: 'true' } + : data + cb(null, JSON.stringify({ success: true, data: result }), '') + return { + stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + } } - }) + ) } function failWith(error: string): void { - execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => { - cb(null, JSON.stringify({ success: false, error }), '') - }) + execFileMock.mockImplementation( + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + cb(null, JSON.stringify({ success: false, error }), '') + } + ) } class TestEvent { @@ -352,7 +360,7 @@ describe('AgentBrowserBridge', () => { try { const closeKill = vi.fn() execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { return { kill: closeKill } } @@ -445,7 +453,7 @@ describe('AgentBrowserBridge', () => { let releaseSnapshot: (() => void) | null = null const activeChild = { kill: vi.fn() } execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('snapshot')) { releaseSnapshot = () => { cb(null, JSON.stringify({ success: false, error: CDP_DISCOVERY_FAILURE }), '') @@ -486,7 +494,7 @@ describe('AgentBrowserBridge', () => { it('handles malformed JSON from agent-browser', async () => { execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, 'not json at all', '') } ) @@ -748,7 +756,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -782,7 +790,7 @@ describe('AgentBrowserBridge', () => { let releaseSnapshot: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -877,7 +885,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -1024,7 +1032,7 @@ describe('AgentBrowserBridge', () => { let releaseFirstScreenshot: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -1103,7 +1111,7 @@ describe('AgentBrowserBridge', () => { webContentsFromIdMock.mockReturnValue(wc) execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, JSON.stringify({ success: true, data: null }), '') } ) @@ -1137,7 +1145,7 @@ describe('AgentBrowserBridge', () => { const killedError = Object.assign(new Error('timeout'), { killed: true }) execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -1167,7 +1175,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] let releaseDestroyClose: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) if (args.includes('close')) { if (!releaseDestroyClose) { @@ -1212,7 +1220,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] let releaseStaleClose: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) if (args.includes('close') && !releaseStaleClose) { releaseStaleClose = () => { @@ -1270,7 +1278,7 @@ describe('AgentBrowserBridge', () => { } execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('snapshot')) { resolveRunningCommand = () => cb(killedError, '', '') return activeChild @@ -1354,7 +1362,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -1390,7 +1398,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -1577,7 +1585,7 @@ describe('AgentBrowserBridge', () => { let releaseSnapshot: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -1875,7 +1883,7 @@ describe('AgentBrowserBridge', () => { let helperSessionIsStale = false execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') } else if (args.includes('snapshot')) { @@ -2549,7 +2557,7 @@ describe('AgentBrowserBridge', () => { it('returns browser_timeout for timed conditional waits without recycling the session', async () => { const killedError = Object.assign(new Error('timeout'), { killed: true }) execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('wait')) { cb(killedError, '', '') return @@ -2573,7 +2581,7 @@ describe('AgentBrowserBridge', () => { it('passes stderr through as error message on execFile failure', async () => { execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -2586,7 +2594,7 @@ describe('AgentBrowserBridge', () => { it('falls back to error.message when stderr is empty', async () => { execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -2601,7 +2609,7 @@ describe('AgentBrowserBridge', () => { it('returns browser_error with truncated output for malformed JSON', async () => { execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, 'Error: not json output', '') } ) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index e7703a1f5e5..22b891cd2a0 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -596,7 +596,7 @@ async function importValidatedCookies( } } diag( - ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${err}` + ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` ) } } @@ -1019,7 +1019,7 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult return { key: Buffer.from(result, 'base64'), mode: 'aes-256-gcm' } } catch (err) { - diag(` Windows DPAPI key extraction failed: ${err}`) + diag(` Windows DPAPI key extraction failed: ${String(err)}`) return null } } @@ -1339,7 +1339,7 @@ async function importCookiesFromFirefox( return importValidatedCookies(validated, rows.length, targetPartition) } catch (err) { rmSync(tmpDir, { recursive: true, force: true }) - diag(` Firefox import failed: ${err}`) + diag(` Firefox import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Firefox. Try closing Firefox first.' @@ -1361,7 +1361,7 @@ async function importCookiesFromSafari( try { data = readFileSync(browser.cookiesPath) } catch (err) { - diag(` Safari read failed: ${err}`) + diag(` Safari read failed: ${String(err)}`) // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. const isPermError = err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' @@ -1392,7 +1392,7 @@ async function importCookiesFromSafari( return importValidatedCookies(valid, cookies.length, targetPartition) } catch (err) { - diag(` Safari import failed: ${err}`) + diag(` Safari import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Safari.' } } } @@ -1480,7 +1480,7 @@ export async function importCookiesFromBrowser( } catch { /* best-effort */ } - diag(` Chromium snapshot failed: ${err}`) + diag(` Chromium snapshot failed: ${String(err)}`) return { ok: false, reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` @@ -1527,7 +1527,7 @@ export async function importCookiesFromBrowser( placeholders = targetCols.map(() => '?').join(', ') stagingDb.exec('DELETE FROM cookies') } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${err}`) + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) stagingAvailable = false targetColumnInfo = null colList = null @@ -1807,7 +1807,7 @@ export async function importCookiesFromBrowser( } catch { /* may not exist yet */ } - diag(` SQLite import failed: ${err}`) + diag(` SQLite import failed: ${String(err)}`) return { ok: false, reason: reasonWithDiagLog( @@ -1818,7 +1818,7 @@ export async function importCookiesFromBrowser( try { sourceSnapshot.cleanup() } catch (err) { - diag(` Chromium snapshot cleanup failed: ${err}`) + diag(` Chromium snapshot cleanup failed: ${String(err)}`) } } } diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index 96f8db9d2dc..b69ec9998e6 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -770,4 +770,94 @@ describe('setupGuestShortcutForwarding', () => { expect(nextDownPreventDefault).not.toHaveBeenCalled() expect(rendererSendMock).not.toHaveBeenCalledWith('ui:openQuickOpen') }) + + // A floating-workspace-owned guest routes close/index chords to the floating-scoped + // IPC *carrying its source id*; a main-owned guest keeps main routing. + describe('source-aware close/index routing', () => { + const closeInput = { code: 'KeyW', key: 'w' } + // workspace.selectByIndex default is Mod+1; tab.selectByIndex default is Ctrl+1 (darwin) / Alt+1 (other). + const workspaceIndexInput = { code: 'Digit1', key: '1' } + const tabIndexInput = + process.platform === 'darwin' + ? { code: 'Digit1', key: '1', control: true, meta: false } + : { code: 'Digit1', key: '1', alt: true, control: false } + + it('routes tab.close to ui:closeFloatingItem with the source id for a floating guest', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + resolveWorktreeId: () => 'global-floating-terminal' + }) + + const preventDefault = triggerBeforeInput(closeInput) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenCalledWith('ui:closeFloatingItem', { + sourceId: browserTabId + }) + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeActiveTab') + }) + + it('routes workspace/tab index chords to ui:selectFloatingIndex for a floating guest', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + resolveWorktreeId: () => 'global-floating-terminal' + }) + + triggerBeforeInput(workspaceIndexInput) + triggerBeforeInput(tabIndexInput) + + expect(rendererSendMock).toHaveBeenCalledWith('ui:selectFloatingIndex', { index: 0 }) + expect(rendererSendMock).toHaveBeenCalledTimes(2) + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', 0) + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:jumpToTabIndex', 0) + }) + + it('keeps main routing for a non-floating guest', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + resolveWorktreeId: () => 'some-worktree' + }) + + triggerBeforeInput(closeInput) + triggerBeforeInput(workspaceIndexInput) + + expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab') + expect(rendererSendMock).toHaveBeenCalledWith('ui:jumpToWorktreeIndex', 0) + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything()) + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:selectFloatingIndex', expect.anything()) + }) + + it('keeps main routing when no worktree resolver is provided', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + triggerBeforeInput(closeInput) + + expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab') + expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything()) + }) + + it('drops auto-repeat index chords at the source for a floating guest', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + resolveWorktreeId: () => 'global-floating-terminal' + }) + + const preventDefault = triggerBeforeInput({ ...workspaceIndexInput, isAutoRepeat: true }) + + expect(preventDefault).not.toHaveBeenCalled() + expect(rendererSendMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index c546b6230d7..3e438c988cd 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -14,6 +14,7 @@ import { } from '../../shared/window-shortcut-policy' import { readGuestNavigationState } from './browser-guest-navigation-state' import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' import { ModifierDoubleTapDetector, @@ -261,6 +262,8 @@ export function setupGuestShortcutForwarding(args: { shouldForwardDictationShortcut?: ShouldForwardDictationShortcut isMobileEmulatorEnabled?: IsMobileEmulatorEnabled getKeybindings?: () => KeybindingOverrides | undefined + // Why: a floating-panel guest owns a distinct workspace; its close/index chords must route to the panel, not the main tab strip. + resolveWorktreeId?: (browserTabId: string) => string | null }): () => void { const { browserTabId, @@ -268,7 +271,8 @@ export function setupGuestShortcutForwarding(args: { resolveRenderer, shouldForwardDictationShortcut, isMobileEmulatorEnabled, - getKeybindings + getKeybindings, + resolveWorktreeId } = args let ctrlTabSwitching = false const doubleTapDetector = new ModifierDoubleTapDetector() @@ -364,6 +368,8 @@ export function setupGuestShortcutForwarding(args: { if (!renderer) { return false } + // Why: floating-panel guests route close/index chords to the panel (carrying their source id) so they hit the floating workspace, not the main tab strip. + const isFloatingGuest = resolveWorktreeId?.(browserTabId) === FLOATING_TERMINAL_WORKTREE_ID if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) { renderer.send('ui:newBrowserTab') } else if ( @@ -400,7 +406,11 @@ export function setupGuestShortcutForwarding(args: { // Why: same as browser.back; the focused guest cannot call the renderer-owned webview's goForward() directly. renderer.send('ui:browserHistoryNavigate', 'forward') } else if (keybindingMatchesAction('tab.close', input, process.platform, keybindings)) { - renderer.send('ui:closeActiveTab') + if (isFloatingGuest) { + renderer.send('ui:closeFloatingItem', { sourceId: browserTabId }) + } else { + renderer.send('ui:closeActiveTab') + } } else if (keybindingMatchesAction('tab.nextSameType', input, process.platform, keybindings)) { renderer.send('ui:switchTab', 1) } else if ( @@ -424,9 +434,17 @@ export function setupGuestShortcutForwarding(args: { } else if (action?.type === 'forceReload') { renderer.reloadIgnoringCache() } else if (action?.type === 'jumpToWorktreeIndex') { - renderer.send('ui:jumpToWorktreeIndex', action.index) + if (isFloatingGuest) { + renderer.send('ui:selectFloatingIndex', { index: action.index }) + } else { + renderer.send('ui:jumpToWorktreeIndex', action.index) + } } else if (action?.type === 'jumpToTabIndex') { - renderer.send('ui:jumpToTabIndex', action.index) + if (isFloatingGuest) { + renderer.send('ui:selectFloatingIndex', { index: action.index }) + } else { + renderer.send('ui:jumpToTabIndex', action.index) + } } else if (action?.type === 'dictationKeyDown') { if (!shouldForwardDictationShortcut?.()) { return false diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index bd0e5698cd8..6b51dc9a0dd 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,14 +43,17 @@ import { buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' -import type { BrowserViewportOverride } from '../../shared/types' +import type { + BrowserViewportOverride, + BrowserCertificateFailure, + BrowserLoadError +} from '../../shared/types' import { type BrowserAnnotationViewportBridgeOptions, BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' -import type { BrowserCertificateFailure, BrowserLoadError } from '../../shared/types' import { BrowserCertificateTrustController, type ManagedBrowserGuestContext @@ -1711,7 +1714,8 @@ export class BrowserManager { resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, - getKeybindings: () => this.settingsResolver?.().keybindings + getKeybindings: () => this.settingsResolver?.().keybindings, + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null }) ) } diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index daaae60540e..b3b1fae9401 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -250,7 +250,7 @@ async function sendRequest( let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk const newlineIndex = buffer.indexOf('\n') if (newlineIndex === -1) { diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 910e1786a7b..49d89c33d95 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -170,9 +170,9 @@ function isKeychainNotFoundError(error: unknown): boolean { : undefined const message = error && typeof error === 'object' - ? `${(error as { stderr?: unknown }).stderr ?? ''} ${ + ? `${String((error as { stderr?: unknown }).stderr ?? '')} ${String( (error as { message?: unknown }).message ?? '' - }`.toLowerCase() + )}`.toLowerCase() : String(error).toLowerCase() return code === 44 || message.includes('could not be found') || message.includes('not be found') } diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 18d2fcc8f18..9c120c230bc 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -654,7 +654,7 @@ export class CliInstaller { private isWindowsPackagedBundledCommand( commandPath: string | null, launcherPath: string | null - ): commandPath is string { + ): boolean { return ( this.platform === 'win32' && this.isPackaged && diff --git a/src/main/codex-accounts/runtime-selection.ts b/src/main/codex-accounts/runtime-selection.ts index a8479dba19a..5ac9f91b013 100644 --- a/src/main/codex-accounts/runtime-selection.ts +++ b/src/main/codex-accounts/runtime-selection.ts @@ -3,28 +3,23 @@ import type { CodexManagedAccountRuntimeSelection, GlobalSettings } from '../../shared/types' +// Why: the renderer's switch-time lane guard has to key panes the same way a +// launch does, so the lane vocabulary lives in shared rather than in main. +import { + getWslSelectionKey, + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget +} from '../../shared/codex-selection-lane' -export type CodexAccountSelectionTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -export type NormalizedCodexAccountSelectionTarget = { - runtime: 'host' | 'wsl' - wslDistro: string | null -} - -export function normalizeCodexAccountSelectionTarget( - target?: CodexAccountSelectionTarget | null -): NormalizedCodexAccountSelectionTarget { - if (target?.runtime === 'wsl') { - return { - runtime: 'wsl', - wslDistro: normalizeWslDistro(target.wslDistro) - } - } - return { runtime: 'host', wslDistro: null } -} +export { + getCodexSelectionLaneKey, + getWslSelectionKey, + normalizeCodexAccountSelectionTarget +} from '../../shared/codex-selection-lane' +export type { + CodexAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget +} from '../../shared/codex-selection-lane' export function normalizeCodexRuntimeSelection( settings: Pick< @@ -135,18 +130,3 @@ export function getCodexSelectionTargetForAccount( } return { runtime: 'host' } } - -/** Stable identifier for the selection lane a launch resolves its account from. */ -export function getCodexSelectionLaneKey(target?: CodexAccountSelectionTarget | null): string { - const normalized = normalizeCodexAccountSelectionTarget(target) - return normalized.runtime === 'host' ? 'host' : `wsl:${getWslSelectionKey(normalized.wslDistro)}` -} - -export function getWslSelectionKey(wslDistro: string | null | undefined): string { - return normalizeWslDistro(wslDistro) ?? '__default__' -} - -function normalizeWslDistro(wslDistro: string | null | undefined): string | null { - const trimmed = wslDistro?.trim() - return trimmed ? trimmed : null -} diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts index 852da6b2c91..5addbdd03f0 100644 --- a/src/main/codex/codex-legacy-session-resume.test.ts +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync, + linkSync, mkdtempSync, mkdirSync, readFileSync, @@ -9,7 +10,11 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, dirname, join } from 'node:path' +import { + codexRolloutHardlinkIdentity, + dedupeCodexRolloutFileAliases +} from '../ai-vault/codex-session-root-dedup' import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' describe('prepareLegacySharedCodexSessionResume', () => { @@ -117,6 +122,15 @@ describe('prepareLegacySharedCodexSessionResume', () => { } ) + it('still materializes a legacy resume when an account selection exists', async () => { + const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), { + ...options(), + getSelectedHostAccountCodexHomePath: () => join(root, 'codex-accounts', 'account-1', 'home') + }) + + expect(result).toEqual({ useRealCodexHome: true }) + }) + function prepare() { return prepareLegacySharedCodexSessionResume(legacyArgs(), options()) } @@ -143,6 +157,174 @@ describe('prepareLegacySharedCodexSessionResume', () => { } }) +// Why: the session bridge hardlinks each rollout into every per-account home +// and vault dedup keeps the lexicographically-smallest alias, so a session +// recorded by the selected account can surface under a peer account's home. +describe('per-account resume repin', () => { + let root: string + // Deliberately ordered so the SELECTED account loses the path tie-break. + let peerHome: string + let selectedHome: string + let recordedRolloutPath: string + let bridgedRolloutPath: string + + const rolloutRelativePath = join( + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T10-00-00-abcdef00-1234-4321-9999-cafecafecafe.jsonl' + ) + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-account-repin-')) + peerHome = join(root, 'codex-accounts', '11111111-aaaa-4aaa-8aaa-111111111111', 'home') + selectedHome = join(root, 'codex-accounts', '99999999-bbbb-4bbb-8bbb-999999999999', 'home') + recordedRolloutPath = join(selectedHome, rolloutRelativePath) + bridgedRolloutPath = join(peerHome, rolloutRelativePath) + mkdirSync(dirname(recordedRolloutPath), { recursive: true }) + writeFileSync(recordedRolloutPath, '{"type":"session_meta"}\n', 'utf-8') + mkdirSync(dirname(bridgedRolloutPath), { recursive: true }) + linkSync(recordedRolloutPath, bridgedRolloutPath) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it.each([['recorded rollout first'], ['bridged alias first']])( + 'repins the surviving vault row to the selected account, discovered %s', + async (order) => { + const recorded = rolloutCandidate(recordedRolloutPath, selectedHome) + const bridged = rolloutCandidate(bridgedRolloutPath, peerHome) + const survivors = dedupeCodexRolloutFileAliases( + order === 'recorded rollout first' ? [recorded, bridged] : [bridged, recorded], + rolloutCandidateAccessors + ) + + // The dedup pick is order-independent: the peer's alias wins the tie-break. + expect(survivors).toEqual([bridged]) + + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: bridged.filePath, + codexHome: peerHome, + executionHostId: 'local' + }, + repinOptions() + ) + + expect(result).toEqual({ useRealCodexHome: false, substituteCodexHome: selectedHome }) + } + ) + + it('keeps the home of a row that already names the selected account', async () => { + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: recordedRolloutPath, + codexHome: selectedHome, + executionHostId: 'local' + }, + repinOptions() + ) + + expect(result).toEqual({ useRealCodexHome: false }) + }) + + it('declines while the system default is selected', async () => { + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: bridgedRolloutPath, + codexHome: peerHome, + executionHostId: 'local' + }, + { ...repinOptions(), getSelectedHostAccountCodexHomePath: () => null } + ) + + expect(result).toEqual({ useRealCodexHome: false }) + }) + + it('declines while the bridge has not yet linked the rollout into the selected home', async () => { + const unbridgedSelectedHome = join( + root, + 'codex-accounts', + 'ffffffff-cccc-4ccc-8ccc-ffffffffffff', + 'home' + ) + mkdirSync(unbridgedSelectedHome, { recursive: true }) + + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: bridgedRolloutPath, + codexHome: peerHome, + executionHostId: 'local' + }, + { ...repinOptions(), getSelectedHostAccountCodexHomePath: () => unbridgedSelectedHome } + ) + + expect(result).toEqual({ useRealCodexHome: false }) + }) + + it('declines a session owned by another host', async () => { + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: bridgedRolloutPath, + codexHome: peerHome, + executionHostId: 'ssh:server-1' as 'local' + }, + repinOptions() + ) + + expect(result).toEqual({ useRealCodexHome: false }) + }) + + it('declines a transcript outside the dated rollout layout', async () => { + const straySessionPath = join(peerHome, 'sessions', 'stray.jsonl') + writeFileSync(straySessionPath, '{"type":"session_meta"}\n', 'utf-8') + + const result = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + filePath: straySessionPath, + codexHome: peerHome, + executionHostId: 'local' + }, + repinOptions() + ) + + expect(result).toEqual({ useRealCodexHome: false }) + }) + + function repinOptions() { + return { + isHostSystemDefaultRealHome: () => false, + getSelectedHostAccountCodexHomePath: () => selectedHome + } + } + + function rolloutCandidate(filePath: string, codexHome: string) { + const stat = statSync(filePath) + return { + filePath, + codexHome, + file: { dev: stat.dev, ino: stat.ino, nlink: stat.nlink } + } + } +}) + +const rolloutCandidateAccessors = { + isCodex: () => true, + getFilePath: (candidate: { filePath: string }) => candidate.filePath, + getCodexHome: (candidate: { codexHome: string }) => candidate.codexHome, + getHardlinkIdentity: (candidate: { file: { dev: number; ino: number; nlink: number } }) => + codexRolloutHardlinkIdentity(candidate.file) +} + function rootPlaceholder(): string { return '__ROOT__' } diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index 3976b4a0d8d..c71b3bfebc0 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -6,6 +6,7 @@ import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' +import { isPerAccountManagedCodexHome } from '../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { @@ -27,10 +28,15 @@ export async function prepareLegacySharedCodexSessionResume( args: AiVaultPrepareSessionResumeArgs, options: { isHostSystemDefaultRealHome: () => boolean + getSelectedHostAccountCodexHomePath?: () => string | null legacyCodexHomePath?: string systemCodexHomePath?: string } ): Promise { + const substituteCodexHome = await resolveSelectedAccountCodexHomeForResume(args, options) + if (substituteCodexHome) { + return { useRealCodexHome: false, substituteCodexHome } + } const paths = resolveCodexSessionBackfillPaths(options.systemCodexHomePath) const legacyCodexHomePath = options.legacyCodexHomePath ?? dirname(paths.managedSessionsRoot) const managedSessionsRoot = join(legacyCodexHomePath, 'sessions') @@ -46,7 +52,7 @@ export async function prepareLegacySharedCodexSessionResume( const sourcePath = resolve(args.filePath) const relativePath = relative(resolve(managedSessionsRoot), sourcePath) - if (!isLegacyRolloutRelativePath(relativePath)) { + if (!isDatedRolloutRelativePath(relativePath)) { throw new Error(RETRYABLE_RESUME_ERROR) } const targetPath = join(paths.systemSessionsRoot, relativePath) @@ -78,6 +84,47 @@ export async function prepareLegacySharedCodexSessionResume( return { useRealCodexHome: true } } +/** + * Repins a per-account resume to the selected account's home, or null to keep + * the session's own home. + * + * Why: the session bridge hardlinks each rollout into every per-account home, + * and vault dedup keeps the lexicographically-smallest alias — which names an + * arbitrary account. When the selected account's home holds the same rollout + * at the same sessions-relative path, resume must run under that account's + * credentials. Every uncertain branch (no selection, unbridged rollout, odd + * layout) declines, so resume degrades to today's behavior instead of failing. + */ +async function resolveSelectedAccountCodexHomeForResume( + args: AiVaultPrepareSessionResumeArgs, + options: { getSelectedHostAccountCodexHomePath?: () => string | null } +): Promise { + if ( + args.agent !== 'codex' || + args.executionHostId !== LOCAL_EXECUTION_HOST_ID || + !args.codexHome || + !isPerAccountManagedCodexHome(args.codexHome) + ) { + return null + } + const selectedCodexHome = options.getSelectedHostAccountCodexHomePath?.() ?? null + if (!selectedCodexHome || sameRuntimePath(selectedCodexHome, args.codexHome)) { + return null + } + const relativePath = relative(resolve(join(args.codexHome, 'sessions')), resolve(args.filePath)) + if (!isDatedRolloutRelativePath(relativePath)) { + return null + } + const candidatePath = join(selectedCodexHome, 'sessions', relativePath) + try { + const candidateStat = await lstat(candidatePath) + // Why: the bridge is async, so an unbridged rollout is a real state — decline rather than pin a home codex cannot resume from. + return candidateStat.isFile() && !candidateStat.isSymbolicLink() ? selectedCodexHome : null + } catch { + return null + } +} + async function materializeLegacyRollout( sourcePath: string, targetPath: string, @@ -152,7 +199,7 @@ async function fileDigest(filePath: string): Promise { return hash.digest('hex') } -function isLegacyRolloutRelativePath(relativePath: string): boolean { +function isDatedRolloutRelativePath(relativePath: string): boolean { if (!relativePath || relativePath.startsWith('..') || resolve(relativePath) === relativePath) { return false } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index b5ad926736d..f1d83989453 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -139,6 +139,26 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } +/** + * Reports the lane each given PTY launched from, omitting panes with no record. + * + * Why the renderer needs this: an account switch has to know which panes the + * change could have stranded, and this key was written from the shell, cwd and + * distro the spawn actually resolved. Re-deriving it from current settings + * answers for a launch that never happened once the user edits those settings. + */ +export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record { + const registry = readRegistry() + const lanesByPtyId: Record = {} + for (const ptyId of ptyIds) { + const record = registry.panes[ptyId] + if (record) { + lanesByPtyId[ptyId] = record.selectionKey + } + } + return lanesByPtyId +} + export const _internals = { resetCache: (): void => { cachedRegistry = null diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index 9e28dbbdc2a..7814994c758 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -8,12 +8,12 @@ import { rmSync, symlinkSync, statSync, - writeFileSync + writeFileSync, + existsSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' -import { existsSync } from 'node:fs' import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { computeTrustKey, diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 60a22232d5e..22e02965e0d 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { DaemonClient } from './client' -import { encodeNdjson } from './ndjson' +import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson' import type { HelloMessage, DaemonRequest, DaemonEvent } from './types' import { getDaemonSocketPath } from './daemon-spawner' @@ -328,6 +328,30 @@ describe('DaemonClient', () => { }) describe('RPC', () => { + it('rejects an oversized request before installing a timer or writing', async () => { + await startMockDaemon() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const internals = client as unknown as { + controlSocket: Socket + pendingRequests: Map + } + const writeSpy = vi.spyOn(internals.controlSocket, 'write') + const timerSpy = vi.spyOn(globalThis, 'setTimeout') + try { + await expect( + client.request('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) }) + ).rejects.toBeInstanceOf(NdjsonLineTooLongError) + + expect(timerSpy).not.toHaveBeenCalled() + expect(writeSpy).not.toHaveBeenCalled() + expect(internals.pendingRequests.size).toBe(0) + } finally { + timerSpy.mockRestore() + writeSpy.mockRestore() + } + }) + it('sends request and receives response', async () => { await startMockDaemon({ onControlMessage: (msg) => { @@ -562,5 +586,29 @@ describe('DaemonClient', () => { client = new DaemonClient({ socketPath, tokenPath }) expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false) }) + + it('reports a dropped delivery for an oversized payload without writing', async () => { + await startMockDaemon() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const internals = client as unknown as { controlSocket: Socket } + const writeSpy = vi.spyOn(internals.controlSocket, 'write') + + expect(client.notify('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })).toBe(false) + expect(writeSpy).not.toHaveBeenCalled() + }) + + it('reports a dropped delivery when the socket write throws', async () => { + await startMockDaemon() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const internals = client as unknown as { controlSocket: Socket } + vi.spyOn(internals.controlSocket, 'write').mockImplementation(() => { + throw new Error('EPIPE') + }) + + // Swallowed, not rethrown: a dead socket must not tear down the caller. + expect(client.notify('write', { sessionId: 'session-1', data: 'hello' })).toBe(false) + }) }) }) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 277b7278ff5..b6229521c8b 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -199,6 +199,7 @@ export class DaemonClient { const id = `req-${++this.requestCounter}` const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } + const encoded = encodeNdjson(msg) return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -212,7 +213,7 @@ export class DaemonClient { timer }) - this.controlSocket!.write(encodeNdjson(msg)) + this.controlSocket!.write(encoded) }) } @@ -224,8 +225,13 @@ export class DaemonClient { const id = `${NOTIFY_PREFIX}${++this.requestCounter}` const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } - this.controlSocket.write(encodeNdjson(msg)) - return true + try { + this.controlSocket.write(encodeNdjson(msg)) + return true + } catch { + // Notifications are best-effort; an oversized payload must not tear down the caller. + return false + } } onEvent(listener: (event: unknown) => void): () => void { diff --git a/src/main/daemon/daemon-checkpoint-file.ts b/src/main/daemon/daemon-checkpoint-file.ts index d7e4bd7a6a5..311a5577484 100644 --- a/src/main/daemon/daemon-checkpoint-file.ts +++ b/src/main/daemon/daemon-checkpoint-file.ts @@ -10,11 +10,13 @@ export type TerminalCheckpointFile = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + pendingEscapeTailAnsi?: string cwd: string | null cols: number rows: number modes: TerminalModes scrollbackLines: number + lastTitle?: string /** Ties this checkpoint to the output.log whose header carries the same * generation. Absent on checkpoints written before incremental logs. */ generation?: number diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 790042c840a..a9e5285c8f6 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(29) + expect(PROTOCOL_VERSION).toBe(30) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -12,5 +12,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index e01a4f58053..db00c375065 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, @@ -11,25 +12,27 @@ import { } from './daemon-protocol-version' describe('daemon protocol version', () => { - it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => { - expect(PROTOCOL_VERSION).toBe(29) + it('ships bounded history transfer after the 2031-unsubscribe fact', () => { + expect(PROTOCOL_VERSION).toBe(30) + expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30) expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29) expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11) expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 28 }, (_, index) => index + 1) + Array.from({ length: 29 }, (_, index) => index + 1) ) }) - it('withholds 2031-unsubscribe support from every preserved older daemon', () => { + it('withholds 2031-unsubscribe support only before its v29 boundary', () => { // Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app // update is the live hazard — it emits '2031-subscribe' with no way to retract it. // The boundary must sit at 29, not merely "recent enough". expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true) + expect(supportsMode2031UnsubscribeFact(29)).toBe(true) expect(supportsMode2031UnsubscribeFact(28)).toBe(false) - for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { + for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) => version < 29)) { expect(supportsMode2031UnsubscribeFact(version)).toBe(false) } }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index f999b6c22b4..5a7f8bb99bb 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993). -export const PROTOCOL_VERSION = 29 +// v30 transfers large cold-restore seeds across bounded NDJSON messages. +export const PROTOCOL_VERSION = 30 +export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 @@ -21,7 +22,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28 + 28, 29 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts index a1ee3dcfc66..221a37a3718 100644 --- a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts +++ b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts @@ -206,7 +206,7 @@ describe('DaemonPtyAdapter history recovery', () => { const originalCheckpoint = manager.checkpoint.bind(manager) let malformedLog!: Buffer vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => { - await originalCheckpoint(...args) + const result = await originalCheckpoint(...args) const sessionDir = join(historyDir, getHistorySessionDirName(id)) const checkpoint = JSON.parse(readFileSync(join(sessionDir, 'checkpoint.json'), 'utf-8')) malformedLog = Buffer.concat([ @@ -217,6 +217,7 @@ describe('DaemonPtyAdapter history recovery', () => { ]) ]) writeFileSync(join(sessionDir, 'output.log'), malformedLog) + return result }) await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) @@ -243,13 +244,12 @@ describe('DaemonPtyAdapter history recovery', () => { let releaseCheckpoint!: () => void let checkpointCalls = 0 vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => { - checkpointCalls++ - if (checkpointCalls === 1) { + if (++checkpointCalls === 1) { await new Promise((resolve) => { releaseCheckpoint = resolve }) } - await originalCheckpoint(...args) + return originalCheckpoint(...args) }) const shuttingDown = historyAdapter.shutdown(id, { diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index b37feba878e..af036c85c08 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -16,10 +16,12 @@ import { HeadlessEmulator } from './headless-emulator' import { getHistorySessionDirName } from './history-paths' import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' +import type { PendingOutputRecord } from './types' import type { DaemonFileLog } from './daemon-file-log' import type * as DaemonHealthModule from './daemon-health' import { getDaemonSocketPath } from './daemon-spawner' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({ getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown') @@ -1824,7 +1826,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { snapshot: null } }) - const checkpoint = vi.fn(async () => {}) + const checkpoint = vi.fn(async () => 'committed' as const) const appendIncrements = vi.fn(async () => 'ok' as const) const dispose = vi.fn(async () => {}) const disconnect = vi.fn() @@ -1880,11 +1882,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { function makeCooldownHarness(takeResult: { overflowed: boolean appendResult?: 'ok' | 'needs-checkpoint' + checkpointResult?: 'committed' | 'retryable' | 'unavailable' + snapshotRecords?: PendingOutputRecord[] }): CooldownInternals { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) const request = vi.fn(async (_type: string, payload: Record) => { if (payload.includeSnapshot === true) { - return { records: [], seq: 2, overflowed: false, snapshot: { cols: 80, rows: 24 } } + return { + records: takeResult.snapshotRecords ?? [], + seq: 2, + overflowed: false, + snapshot: { cols: 80, rows: 24 } + } } return { records: [{ kind: 'output', data: 'x' }], @@ -1896,7 +1905,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const internals = historyAdapter as unknown as CooldownInternals internals.client = { request, disconnect: vi.fn() } internals.historyManager = { - checkpoint: vi.fn(async () => {}), + checkpoint: vi.fn(async () => takeResult.checkpointResult ?? 'committed'), appendIncrements: vi.fn(async () => takeResult.appendResult ?? 'ok'), dispose: vi.fn(async () => {}) } @@ -1958,6 +1967,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) expect(internals.sessionsNeedingFullCheckpoint.has('capped')).toBe(false) }) + + it('defers a teardown checkpoint that fails to serialize and drops its held tail', async () => { + const internals = makeCooldownHarness({ + overflowed: false, + checkpointResult: 'retryable', + // Held shell-ready bytes ride out with the teardown snapshot (Session.prepareForFinalSnapshot). + snapshotRecords: [{ kind: 'output', data: 'held tail' }] + }) + + await expect( + internals.checkpointSessions(['sleeping'], { final: true, teardown: true }) + ).resolves.toEqual(new Set()) + + expect(internals.historyManager.checkpoint).toHaveBeenCalledTimes(1) + expect(internals.sessionsNeedingFullCheckpoint.has('sleeping')).toBe(true) + // Why the tail must not be appended: the output this take drained went into the failed snapshot, so the tail + // would land at a contiguous seq over that hole and pass the log's gap detection. + expect(internals.historyManager.appendIncrements).not.toHaveBeenCalled() + expect(internals.lastFullCheckpointAt.has('sleeping')).toBe(false) + }) }) it('does not schedule a checkpoint timer until a session is dirty', async () => { @@ -2229,6 +2258,146 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('uploads a large cold-restore seed in bounded protocol chunks', async () => { + const sessionId = 'chunked-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + const snapshotAnsi = `${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}\r\nCHUNKED-SEED-MARKER` + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/chunked', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync( + join(sessionDir, 'checkpoint.json'), + JSON.stringify({ + snapshotAnsi, + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/projects/chunked', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + scrollbackLines: 0, + generation: 0, + checkpointedAt: '2026-07-25T10:00:00Z' + }) + ) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const requestSpy = vi.spyOn(client, 'request') + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore?.scrollback).toContain('CHUNKED-SEED-MARKER') + expect(requestSpy.mock.calls.map(([type]) => type)).toEqual( + expect.arrayContaining([ + 'startHistorySeedTransfer', + 'appendHistorySeedTransfer', + 'finishHistorySeedTransfer', + 'createOrAttach' + ]) + ) + const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1] + expect(createPayload).toMatchObject({ + historySeedTransferId: expect.any(String) + }) + expect(createPayload).not.toHaveProperty('historySeed') + await expect(historyAdapter.getBufferSnapshot(sessionId)).resolves.toMatchObject({ + data: expect.stringContaining('CHUNKED-SEED-MARKER') + }) + }) + + it('keeps large recovery renderer-only with a preserved legacy daemon', async () => { + await server.shutdown() + server = new DaemonServer({ + socketPath, + tokenPath, + protocolVersion: 29, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + const sessionId = 'legacy-large-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + const checkpointPath = join(sessionDir, 'checkpoint.json') + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/legacy', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync( + checkpointPath, + JSON.stringify({ + snapshotAnsi: `${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}LEGACY-MARKER`, + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/projects/legacy', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + scrollbackLines: 0, + generation: 0, + checkpointedAt: '2026-07-25T10:00:00Z' + }) + ) + historyAdapter = new DaemonPtyAdapter({ + socketPath, + tokenPath, + protocolVersion: 29, + historyPath: historyDir + }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const requestSpy = vi.spyOn(client, 'request') + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore?.scrollback).toContain('LEGACY-MARKER') + expect(requestSpy.mock.calls.map(([type]) => type)).not.toContain('startHistorySeedTransfer') + const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1] + expect(createPayload).not.toHaveProperty('historySeed') + expect(createPayload).not.toHaveProperty('historySeedTransferId') + expect(existsSync(checkpointPath)).toBe(true) + const managerInternals = historyAdapter.getHistoryManager()! as unknown as { + writers: Map + } + expect(managerInternals.writers.has(sessionId)).toBe(false) + }) + it('repairs legacy hostname UNC cwd for WSL spawn and cold-restore metadata', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 57cc9d55e54..e658ef3db26 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -3,8 +3,13 @@ import { basename } from 'node:path' import { existsSync } from 'node:fs' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' -import { HistoryManager, type HistoryRecoveryFreeze } from './history-manager' +import { + HistoryManager, + type HistoryCheckpointResult, + type HistoryRecoveryFreeze +} from './history-manager' import { HistoryReader, type ColdRestoreInfo } from './history-reader' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' @@ -25,6 +30,7 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' +import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' import { isAgentSessionClaimedSpawnResult, isAgentSessionOwnerBinding, @@ -51,6 +57,12 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' +import { + iterateTerminalHistorySeedChunks, + measureTerminalHistorySeed, + TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS +} from './terminal-history-seed-chunks' +import { NdjsonLineTooLongError } from './ndjson' type PendingDaemonSpawnOperation = { exitsBySessionId: Map @@ -74,14 +86,6 @@ function takeRecoveryFreeze( historyRecovery.freeze = null return freeze } - -function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { - // Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen. - return restoreInfo.modes.alternateScreen - ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi -} - function providerSequenceForSpawn( result: CreateOrAttachResult ): PtySpawnResult['providerSequence'] { @@ -412,7 +416,10 @@ export class DaemonPtyAdapter implements IPtyProvider { ? CODEX_SHELL_READY_TIMEOUT_MS : undefined - const createOrAttach = (historySeed: string | null) => { + const requestCreateOrAttach = ( + historySeed: string | undefined, + historySeedTransferId: string | undefined + ) => { if (opts.signal?.aborted) { throw new Error('client_disconnected') } @@ -433,6 +440,7 @@ export class DaemonPtyAdapter implements IPtyProvider { shellReadySupported, ...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), ...(historySeed ? { historySeed } : {}), + ...(historySeedTransferId ? { historySeedTransferId } : {}), ...(this.supportsStartupIngress && opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), @@ -440,7 +448,65 @@ export class DaemonPtyAdapter implements IPtyProvider { }) } - let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null + const createOrAttach = async ( + historySeedSegments: readonly string[] | null + ): Promise => { + // Why scoped per call: the aliveness-probe retry re-runs this with its own seed, so a first-call + // delivery failure must not force historySeeded=false on a retry that seeded successfully. + let historySeedUnavailable = false + const deliverSeedAndCreate = async (): Promise => { + if (!historySeedSegments || historySeedSegments.length === 0) { + return requestCreateOrAttach(undefined, undefined) + } + const metrics = measureTerminalHistorySeed(historySeedSegments) + if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { + try { + return await requestCreateOrAttach(historySeedSegments.join(''), undefined) + } catch (error) { + if (!(error instanceof NdjsonLineTooLongError)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + } + if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + + let transferId: string | undefined + try { + const started = await this.client.request<{ transferId: string }>( + 'startHistorySeedTransfer', + metrics + ) + transferId = started.transferId + let index = 0 + for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { + await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) + index += 1 + } + await this.client.request('finishHistorySeedTransfer', { transferId }) + } catch (error) { + if (transferId) { + await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) + } + if (isDaemonGoneError(error)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + return requestCreateOrAttach(undefined, transferId) + } + const result = await deliverSeedAndCreate() + return historySeedUnavailable && result.historySeeded === undefined + ? { ...result, historySeeded: false } + : result + } + + let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { const requestedSessionId = sessionId if ( @@ -463,9 +529,9 @@ export class DaemonPtyAdapter implements IPtyProvider { historyRecovery.unreadableSessionId = null historyRecovery.identityChanged = true restoreInfo = null - scrollback = null + historySeedSegments = null } - let result = await createOrAttach(scrollback) + let result = await createOrAttach(historySeedSegments) await adoptSpawnResultSession(result) // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. this.clearSessionAwaitingDaemonRecovery(requestedSessionId) @@ -528,8 +594,8 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) - scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null - if (restoreInfo && scrollback) { + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { // Why: the aliveness probe raced with session death, so the first // create lacked recovery bytes. Replace it before exposing the PTY. if (result.incarnationId) { @@ -540,7 +606,7 @@ export class DaemonPtyAdapter implements IPtyProvider { effectiveCwd = restoreInfo.cwd effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows - result = await createOrAttach(scrollback) + result = await createOrAttach(historySeedSegments) await adoptSpawnResultSession(result) const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) if (exitedRetryResult) { @@ -565,7 +631,7 @@ export class DaemonPtyAdapter implements IPtyProvider { result.historySeeded === false ) { restoreInfo = await detectColdRestore() - scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null } const wasAlreadyManaged = this.activeSessionIds.has(sessionId) @@ -575,7 +641,8 @@ export class DaemonPtyAdapter implements IPtyProvider { // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. if (restoreInfo && (result.isNew || result.historySeeded === false)) { const coldRestore = this.buildColdRestorePayload(restoreInfo) - const canReanchorHistory = !scrollback || result.historySeeded === true + const canReanchorHistory = + !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. if (this.historyManager && !historyRecovery.identityChanged) { const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) @@ -1649,7 +1716,8 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!this.supportsIncrementalCheckpoints) { const result = await this.client.request('getSnapshot', { sessionId }) if (result.snapshot && this.historyManager) { - await this.historyManager.checkpoint(sessionId, result.snapshot) + const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) + return checkpoint === 'retryable' ? 'deferred' : 'done' } return 'done' } @@ -1659,7 +1727,13 @@ export class DaemonPtyAdapter implements IPtyProvider { } // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). - await this.takeSnapshotAndCheckpoint(sessionId, { teardown: opts.teardown }) + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: opts.teardown + }) + if (checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } this.sessionsNeedingFullCheckpoint.delete(sessionId) return 'done' } @@ -1675,7 +1749,11 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.add(sessionId) return 'deferred' } - await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) + if (checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } return 'done' } if (take.records.length === 0) { @@ -1695,7 +1773,11 @@ export class DaemonPtyAdapter implements IPtyProvider { this.sessionsNeedingFullCheckpoint.add(sessionId) return 'deferred' } - await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false }) + if (checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } } return 'done' } @@ -1703,20 +1785,28 @@ export class DaemonPtyAdapter implements IPtyProvider { private async takeSnapshotAndCheckpoint( sessionId: string, opts: { teardown: boolean } - ): Promise { + ): Promise { const take = await this.client.request('takePendingOutput', { sessionId, includeSnapshot: true, teardownSnapshot: opts.teardown }) if (take?.snapshot && this.historyManager) { - await this.historyManager.checkpoint(sessionId, take.snapshot) + const checkpoint = await this.historyManager.checkpoint(sessionId, take.snapshot) + if (checkpoint !== 'committed') { + // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that + // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and + // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. + return checkpoint + } this.lastFullCheckpointAt.set(sessionId, Date.now()) if (take.records.length > 0) { // Why: held parser-state bytes (an incomplete shell-ready marker) aren't in the snapshot; keep them as a post-checkpoint log tail. await this.historyManager.appendIncrements(sessionId, take.seq, take.records) } + return 'committed' } + return 'unavailable' } // Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart. diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 0ac8d158b27..0d8123ce11b 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -36,6 +36,7 @@ import { isAgentSessionExecutionClaim, isAgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' +import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' export type DaemonServerOptions = { socketPath: string @@ -152,6 +153,7 @@ export class DaemonServer { private streamClientIdBySessionId = new Map() private lastInputAtBySessionId = new Map() private pendingPtySpawnPreparations = new Map>() + private historySeedTransfers = new TerminalHistorySeedTransferRegistry() private stopStreamBacklogProbe: () => void = () => {} // Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay. @@ -272,6 +274,7 @@ export class DaemonServer { }) } this.streamDataBatcher.clear() + this.historySeedTransfers.dispose() this.pendingShutdownReplies.clear() for (const [, client] of this.clients) { @@ -475,6 +478,7 @@ export class DaemonServer { if (previous) { // Why: reconnect reuses clientId before stale close fires; cancel the old owner's preflight at handoff. this.cancelPendingPtySpawnPreparationsForClient(hello.clientId) + this.historySeedTransfers.clearOwner(hello.clientId) this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) // Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement. previous.streamSocket?.destroy() @@ -518,6 +522,7 @@ export class DaemonServer { // Why: a client that disconnects mid-preflight would otherwise still create // its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4). this.cancelPendingPtySpawnPreparationsForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) const wasFullyAuthenticated = client.authenticatedPairEstablished this.streamDataBatcher.clear(clientId) client.streamSocket?.destroy() @@ -683,6 +688,31 @@ export class DaemonServer { const client = this.clients.get(clientId) switch (request.type) { + case 'startHistorySeedTransfer': { + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + const transferId = this.historySeedTransfers.start(clientId, request.payload) + return { transferId } + } + + case 'appendHistorySeedTransfer': + this.historySeedTransfers.append( + clientId, + request.payload.transferId, + request.payload.index, + request.payload.data + ) + return {} + + case 'finishHistorySeedTransfer': + this.historySeedTransfers.finish(clientId, request.payload.transferId) + return {} + + case 'abortHistorySeedTransfer': + this.historySeedTransfers.abort(clientId, request.payload.transferId) + return {} + case 'createOrAttach': { if (this.idleShutdownState !== 'running') { throw new Error('Daemon temporarily unavailable; reconnect') @@ -704,6 +734,15 @@ export class DaemonServer { throw new Error('agent_session_identity_required') } await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId) + if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) { + throw new Error('Multiple terminal history seed sources') + } + const historySeedChunks = + p.historySeedTransferId !== undefined + ? this.historySeedTransfers.take(clientId, p.historySeedTransferId) + : p.historySeed !== undefined + ? [p.historySeed] + : undefined result = await this.host.createOrAttach({ sessionId: p.sessionId, cols: p.cols, @@ -719,7 +758,7 @@ export class DaemonServer { terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, - historySeed: p.historySeed, + historySeedChunks, startupIngress: parsePtyStartupIngressIntent(p.startupIngress), ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 8ca0f39bdc1..d0d748a9bfa 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -2,9 +2,15 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { combineUnsubscribes } from './combine-unsubscribes' import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' -import type { IPtyProvider, PtyBackgroundStreamEvent } from '../providers/types' -import type { PtyDataEvent, PtyProviderBufferSnapshot } from '../providers/types' -import type { PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyBackgroundStreamEvent, + PtyDataEvent, + PtyProviderBufferSnapshot, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly routesFreshSpawnsToLocalProvider = true diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index fb574dd64da..7aa786063e7 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -17,23 +17,32 @@ import { type SessionMeta } from './terminal-history-metadata' import type { PendingOutputRecord, TerminalSnapshot } from './types' -import type { HistoryManagerOptions, OpenSessionOptions } from './terminal-history-manager-options' +import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' +import { TerminalHistoryMutationTracker } from './terminal-history-mutation-tracker' +import type { + HistoryCheckpointResult, + HistoryManagerOptions, + OpenSessionOptions +} from './terminal-history-manager-options' export type { SessionMeta } from './terminal-history-metadata' export type { HistoryRecoveryFreeze } from './terminal-history-recovery-quarantine' -export type { HistoryManagerOptions, OpenSessionOptions } from './terminal-history-manager-options' +export type * from './terminal-history-manager-options' export class HistoryManager { - private basePath: string private writers = new Map() private disabledSessions = new Set() - private pendingSessionMutations = new Map>>() + private mutations = new TerminalHistoryMutationTracker() private recoveryFreezes = new Map() private onWriteError?: (sessionId: string, error: Error) => void + private checkpointMaxBytes: number - constructor(basePath: string, opts?: HistoryManagerOptions) { - this.basePath = basePath + constructor( + private readonly basePath: string, + opts?: HistoryManagerOptions + ) { this.onWriteError = opts?.onWriteError + this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } async openSession(sessionId: string, opts: OpenSessionOptions): Promise { @@ -81,7 +90,10 @@ export class HistoryManager { } } - this.writers.set(sessionId, new TerminalHistorySessionWriter(dir, true)) + this.writers.set( + sessionId, + new TerminalHistorySessionWriter(dir, true, this.checkpointMaxBytes) + ) } catch (err) { if (recoveryFreeze) { this.abandonRecoveryFreeze(recoveryFreeze) @@ -103,7 +115,7 @@ export class HistoryManager { const activeFreeze: ActiveHistoryRecoveryFreeze = { handle } this.recoveryFreezes.set(sessionId, activeFreeze) try { - await this.waitForSessionMutations(sessionId) + await this.mutations.wait(sessionId) activeFreeze.fingerprint = fingerprintTerminalHistorySession(this.basePath, sessionId) return handle } catch (err) { @@ -148,7 +160,10 @@ export class HistoryManager { return } const dir = join(this.basePath, getHistorySessionDirName(sessionId)) - this.writers.set(sessionId, new TerminalHistorySessionWriter(dir, false)) + this.writers.set( + sessionId, + new TerminalHistorySessionWriter(dir, false, this.checkpointMaxBytes) + ) } // Why: wake re-spawns a sleep-killed session; re-register without deleting checkpoint.json, clear endedAt so it can cold-restore again. @@ -181,10 +196,7 @@ export class HistoryManager { seq: number, records: PendingOutputRecord[] ): Promise<'ok' | 'needs-checkpoint'> { - return this.trackSessionMutation( - sessionId, - this.appendIncrementsUntracked(sessionId, seq, records) - ) + return this.mutations.track(sessionId, this.appendIncrementsUntracked(sessionId, seq, records)) } private async appendIncrementsUntracked( @@ -208,25 +220,33 @@ export class HistoryManager { } // Full checkpoints are rare (clean disconnect, pending-buffer overflow, log cap); the 5s tick appends increments instead. - checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise { - return this.trackSessionMutation(sessionId, this.checkpointUntracked(sessionId, snapshot)) + checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise { + return this.mutations.track(sessionId, this.checkpointUntracked(sessionId, snapshot)) } - private async checkpointUntracked(sessionId: string, snapshot: TerminalSnapshot): Promise { + private async checkpointUntracked( + sessionId: string, + snapshot: TerminalSnapshot + ): Promise { if (this.disabledSessions.has(sessionId)) { - return + return 'unavailable' } const writer = this.writers.get(sessionId) if (!writer) { - return + return 'unavailable' } try { // Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV). // The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path. - await writer.checkpoint(snapshot) + const checkpoint = await writer.checkpoint(snapshot) + if (checkpoint.result === 'retryable') { + this.onWriteError?.(sessionId, checkpoint.error) + } + return checkpoint.result } catch (err) { this.handleWriteError(sessionId, err) + return 'unavailable' } } @@ -254,7 +274,7 @@ export class HistoryManager { if (activeFreeze) { this.recoveryFreezes.delete(sessionId) } - await this.waitForSessionMutations(sessionId) + await this.mutations.wait(sessionId) rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), { recursive: true, force: true @@ -318,29 +338,4 @@ export class HistoryManager { } return activeFreeze } - - private trackSessionMutation(sessionId: string, operation: Promise): Promise { - const mutations = this.pendingSessionMutations.get(sessionId) ?? new Set>() - mutations.add(operation) - this.pendingSessionMutations.set(sessionId, mutations) - void operation.then( - () => this.finishSessionMutation(sessionId, operation), - () => this.finishSessionMutation(sessionId, operation) - ) - return operation - } - - private finishSessionMutation(sessionId: string, operation: Promise): void { - const mutations = this.pendingSessionMutations.get(sessionId) - mutations?.delete(operation) - if (mutations?.size === 0) { - this.pendingSessionMutations.delete(sessionId) - } - } - - private async waitForSessionMutations(sessionId: string): Promise { - while (this.pendingSessionMutations.has(sessionId)) { - await Promise.allSettled(this.pendingSessionMutations.get(sessionId) ?? []) - } - } } diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 39092b3aa5e..f950c550d99 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -284,11 +284,15 @@ export class HistoryReader { if ( !(await replay.write(checkpoint.scrollbackAnsi ?? '')) || !(await replay.write(checkpoint.rehydrateSequences)) || - !(await replay.write(checkpoint.snapshotAnsi)) + !(await replay.write(checkpoint.snapshotAnsi)) || + !(await replay.write(checkpoint.pendingEscapeTailAnsi ?? '')) ) { return { restoreInfo: null, readFailed: true } } emulator.setRestoredOscLinks(checkpoint.oscLinks) + if (checkpoint.lastTitle) { + emulator.setLastTitle(checkpoint.lastTitle) + } } for (const batch of log.batches) { for (const record of batch.records) { diff --git a/src/main/daemon/json-utf8-byte-length.test.ts b/src/main/daemon/json-utf8-byte-length.test.ts new file mode 100644 index 00000000000..fd05398907d --- /dev/null +++ b/src/main/daemon/json-utf8-byte-length.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { jsonUtf8ByteLength } from './json-utf8-byte-length' + +describe('jsonUtf8ByteLength', () => { + it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => { + const values: unknown[] = [ + '', + '"\\\b\t\n\f\r\u0000\u001f', + 'plain ASCII', + 'é漢😀', + '\ud800 lone high \udc00 lone low', + { + omitted: undefined, + finite: -1.25e100, + nonFinite: Number.POSITIVE_INFINITY, + nested: ['😀', undefined, null, { control: '\u0001' }] + } + ] + + for (const value of values) { + const json = JSON.stringify(value) + expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8')) + } + }) + + it('rejects the same unsupported structural values as JSON.stringify', () => { + const circular: Record = {} + circular.self = circular + + expect(() => jsonUtf8ByteLength(circular)).toThrow('circular') + expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt') + }) +}) diff --git a/src/main/daemon/json-utf8-byte-length.ts b/src/main/daemon/json-utf8-byte-length.ts new file mode 100644 index 00000000000..dafc8b6970a --- /dev/null +++ b/src/main/daemon/json-utf8-byte-length.ts @@ -0,0 +1,94 @@ +function jsonStringUtf8Bytes(value: string): number { + let bytes = 2 + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index) + if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) { + bytes += 2 + } else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) { + bytes += 2 + } else if (codeUnit < 0x20) { + bytes += 6 + } else if (codeUnit < 0x80) { + bytes += 1 + } else if (codeUnit < 0x800) { + bytes += 2 + } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 + index += 1 + } else { + bytes += 6 + } + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + bytes += 6 + } else { + bytes += 3 + } + } + return bytes +} + +export function jsonUtf8ByteLength(value: unknown): number { + const activeObjects = new Set() + + const measure = (current: unknown, arrayElement: boolean): number | null => { + if (current === null) { + return 4 + } + switch (typeof current) { + case 'string': + return jsonStringUtf8Bytes(current) + case 'boolean': + return current ? 4 : 5 + case 'number': + return Number.isFinite(current) ? JSON.stringify(current).length : 4 + case 'undefined': + case 'function': + case 'symbol': + return arrayElement ? 4 : null + case 'bigint': + throw new TypeError('Do not know how to serialize a BigInt') + case 'object': + break + } + + const object = current as object + if (activeObjects.has(object)) { + throw new TypeError('Converting circular structure to JSON') + } + activeObjects.add(object) + try { + if (Array.isArray(object)) { + let bytes = 2 + for (let index = 0; index < object.length; index += 1) { + if (index > 0) { + bytes += 1 + } + bytes += measure(object[index], true) ?? 4 + } + return bytes + } + + let bytes = 2 + let entries = 0 + for (const key of Object.keys(object)) { + const propertyBytes = measure((object as Record)[key], false) + if (propertyBytes === null) { + continue + } + bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes + entries += 1 + } + return bytes + } finally { + activeObjects.delete(object) + } + } + + const bytes = measure(value, false) + if (bytes === null) { + throw new TypeError('Value is not JSON serializable') + } + return bytes +} diff --git a/src/main/daemon/ndjson.test.ts b/src/main/daemon/ndjson.test.ts index 3d1d2c1512b..44020fc20b4 100644 --- a/src/main/daemon/ndjson.test.ts +++ b/src/main/daemon/ndjson.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { encodeNdjson, createNdjsonParser, NDJSON_MAX_LINE_BYTES } from './ndjson' +import { + encodeNdjson, + createNdjsonParser, + NDJSON_MAX_LINE_BYTES, + NdjsonLineTooLongError +} from './ndjson' describe('encodeNdjson', () => { it('encodes an object as a JSON line ending with newline', () => { @@ -13,6 +18,20 @@ describe('encodeNdjson', () => { expect(result.endsWith('\n')).toBe(true) expect(JSON.parse(result.trim())).toEqual(msg) }) + + it('accepts the exact line-byte limit and rejects one byte more', () => { + const emptyBytes = Buffer.byteLength(JSON.stringify({ data: '' }), 'utf8') + expect(encodeNdjson({ data: 'abc' }, emptyBytes + 3)).toBe('{"data":"abc"}\n') + expect(() => encodeNdjson({ data: 'abcd' }, emptyBytes + 3)).toThrow(NdjsonLineTooLongError) + }) + + // Why: the cap is UTF-8 bytes, not characters — a code-unit count would let a 4-byte emoji slip past. + it('measures multibyte payloads in UTF-8 bytes, not characters', () => { + const emptyBytes = Buffer.byteLength(JSON.stringify({ data: '' }), 'utf8') + expect(encodeNdjson({ data: '🐙' }, emptyBytes + 4)).toBe('{"data":"🐙"}\n') + expect(() => encodeNdjson({ data: '🐙' }, emptyBytes + 3)).toThrow(NdjsonLineTooLongError) + expect(() => encodeNdjson({ data: 'é' }, emptyBytes + 1)).toThrow(NdjsonLineTooLongError) + }) }) describe('createNdjsonParser', () => { diff --git a/src/main/daemon/ndjson.ts b/src/main/daemon/ndjson.ts index 17b37cb8d11..2557844bad7 100644 --- a/src/main/daemon/ndjson.ts +++ b/src/main/daemon/ndjson.ts @@ -1,8 +1,23 @@ -export function encodeNdjson(msg: unknown): string { - return `${JSON.stringify(msg)}\n` +export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024 + +export class NdjsonLineTooLongError extends Error { + constructor( + readonly lineBytes: number, + readonly maxLineBytes: number + ) { + super(`NDJSON line exceeds max ${maxLineBytes} bytes (${lineBytes} bytes encoded)`) + this.name = 'NdjsonLineTooLongError' + } } -export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024 +export function encodeNdjson(msg: unknown, maxLineBytes = NDJSON_MAX_LINE_BYTES): string { + const line = JSON.stringify(msg) + const lineBytes = Buffer.byteLength(line, 'utf8') + if (lineBytes > maxLineBytes) { + throw new NdjsonLineTooLongError(lineBytes, maxLineBytes) + } + return `${line}\n` +} export type NdjsonParser = { feed(chunk: string): void diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7edaa500663..7d96484edea 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -80,7 +80,7 @@ export type SessionOptions = { subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number - historySeed?: string + historySeedChunks?: readonly string[] scrollback?: number wslDistro?: string // Fired once the session reaches a terminal state so the owner (TerminalHost) can reap it; without @@ -146,8 +146,12 @@ export class Session { // the authoritative responder and a daemon reply would race ahead and clobber it. See HeadlessEmulator. }) // Why: seed recovery must precede listener registration; shells can emit their prompt synchronously once onData subscribes. + // Why the every() short-circuit is safe: writeSync only fails emulator-wide (disposed / no sync write API), so later + // chunks could not land either — and writing them past a dropped chunk would seed a torn stream. this._historySeeded = - opts.historySeed === undefined ? undefined : this.emulator.writeSync(opts.historySeed) + opts.historySeedChunks === undefined + ? undefined + : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) if (opts.shellReadySupported) { this._shellState = 'pending' diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts new file mode 100644 index 00000000000..595d42fd6a2 --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -0,0 +1,111 @@ +import type { TerminalCheckpointFile, TerminalSnapshot } from './types' +import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' +import { HeadlessEmulator } from './headless-emulator' +import { jsonUtf8ByteLength } from './json-utf8-byte-length' + +type CheckpointMetadata = { + cwd: string | null + generation: number + checkpointedAt: string +} + +function checkpointFile( + snapshot: TerminalSnapshot, + metadata: CheckpointMetadata +): TerminalCheckpointFile { + return { + snapshotAnsi: snapshot.snapshotAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + oscLinks: snapshot.oscLinks, + rehydrateSequences: snapshot.rehydrateSequences, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}), + cwd: metadata.cwd, + cols: snapshot.cols, + rows: snapshot.rows, + modes: snapshot.modes, + scrollbackLines: snapshot.scrollbackLines, + ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}), + generation: metadata.generation, + checkpointedAt: metadata.checkpointedAt + } +} + +function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null { + if (jsonUtf8ByteLength(checkpoint) > maxBytes) { + return null + } + const json = JSON.stringify(checkpoint) + if (Buffer.byteLength(json, 'utf8') > maxBytes) { + throw new Error('Terminal checkpoint size estimator mismatch') + } + return json +} + +async function replaySnapshot(snapshot: TerminalSnapshot): Promise { + const emulator = new HeadlessEmulator({ + cols: snapshot.cols, + rows: snapshot.rows, + scrollback: Math.max(0, Math.min(50_000, snapshot.scrollbackLines)) + }) + const replay = new ColdRestoreReplayWriter(emulator) + try { + for (const segment of [ + snapshot.scrollbackAnsi, + snapshot.rehydrateSequences, + snapshot.snapshotAnsi, + snapshot.pendingEscapeTailAnsi ?? '' + ]) { + if (!(await replay.write(segment))) { + throw new Error('Terminal checkpoint replay is unavailable') + } + } + emulator.setCwd(snapshot.cwd) + if (snapshot.lastTitle) { + emulator.setLastTitle(snapshot.lastTitle) + } + emulator.setRestoredOscLinks(snapshot.oscLinks) + return emulator + } catch (error) { + emulator.dispose() + throw error + } +} + +export async function serializeTerminalCheckpointWithinLimit( + snapshot: TerminalSnapshot, + metadata: CheckpointMetadata, + maxBytes: number +): Promise { + const direct = stringifyWithinLimit(checkpointFile(snapshot, metadata), maxBytes) + if (direct !== null) { + return direct + } + + const emulator = await replaySnapshot(snapshot) + try { + const visibleOnly = emulator.getSnapshot({ scrollbackRows: 0 }) + let bestJson = stringifyWithinLimit(checkpointFile(visibleOnly, metadata), maxBytes) + if (bestJson === null) { + throw new Error('Terminal checkpoint metadata exceeds byte limit') + } + + let low = 1 + let high = visibleOnly.scrollbackLines + while (low <= high) { + const rows = low + Math.floor((high - low) / 2) + const candidate = emulator.getSnapshot({ scrollbackRows: rows }) + const candidateJson = stringifyWithinLimit(checkpointFile(candidate, metadata), maxBytes) + if (candidateJson === null) { + high = rows - 1 + } else { + bestJson = candidateJson + low = rows + 1 + } + } + return bestJson + } finally { + emulator.dispose() + } +} diff --git a/src/main/daemon/terminal-checkpoint-writer-bounds.test.ts b/src/main/daemon/terminal-checkpoint-writer-bounds.test.ts new file mode 100644 index 00000000000..d7a1fd3e9fc --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-writer-bounds.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HistoryManager } from './history-manager' +import { HistoryReader } from './history-reader' +import { getHistorySessionDirName } from './history-paths' +import type { TerminalSnapshot } from './types' + +const SESSION_ID = 'bounded-checkpoint' + +function snapshot(snapshotAnsi: string): TerminalSnapshot { + return { + snapshotAnsi, + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/workspace', + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + cols: 80, + rows: 24, + scrollbackLines: 500 + } +} + +describe('bounded terminal checkpoint writer', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'checkpoint-writer-bounds-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('trims oldest rows and commits a checkpoint within the reader byte contract', async () => { + const maxBytes = 4_000 + const manager = new HistoryManager(dir, { checkpointMaxBytes: maxBytes }) + await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 }) + const lines = Array.from({ length: 500 }, (_, index) => `history-${index}\r\n`).join('') + + await expect(manager.checkpoint(SESSION_ID, snapshot(`${lines}NEWEST-MARKER`))).resolves.toBe( + 'committed' + ) + + const checkpointPath = join(dir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json') + const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8')) + expect(statSync(checkpointPath).size).toBeLessThanOrEqual(maxBytes) + expect(checkpoint.snapshotAnsi).toContain('NEWEST-MARKER') + expect(checkpoint.snapshotAnsi).not.toContain('history-0') + }) + + it('keeps serialization failures retryable without disabling the session', async () => { + const manager = new HistoryManager(dir) + await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 }) + await expect(manager.checkpoint(SESSION_ID, snapshot('stable'))).resolves.toBe('committed') + const checkpointPath = join(dir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json') + const previous = readFileSync(checkpointPath, 'utf8') + const invalid = snapshot('invalid') + const circular: Record = {} + circular.self = circular + invalid.oscLinks = [circular as never] + + await expect(manager.checkpoint(SESSION_ID, invalid)).resolves.toBe('retryable') + expect(manager.isSessionDisabled(SESSION_ID)).toBe(false) + expect(readFileSync(checkpointPath, 'utf8')).toBe(previous) + await expect(manager.checkpoint(SESSION_ID, snapshot('recovered'))).resolves.toBe('committed') + expect(readFileSync(checkpointPath, 'utf8')).toContain('recovered') + }) + + it('persists parser-tail and title metadata when present', async () => { + const manager = new HistoryManager(dir) + await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 }) + + await manager.checkpoint(SESSION_ID, { + ...snapshot('body'), + pendingEscapeTailAnsi: '\x1b[38;5;', + lastTitle: 'Codex working' + }) + + const checkpointPath = join(dir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json') + expect(existsSync(checkpointPath)).toBe(true) + expect(JSON.parse(readFileSync(checkpointPath, 'utf8'))).toMatchObject({ + pendingEscapeTailAnsi: '\x1b[38;5;', + lastTitle: 'Codex working' + }) + }) + + it('replays a persisted parser tail before incremental log output', async () => { + const manager = new HistoryManager(dir) + await manager.openSession(SESSION_ID, { cwd: '/workspace', cols: 80, rows: 24 }) + await manager.checkpoint(SESSION_ID, { + ...snapshot('base\r\n'), + pendingEscapeTailAnsi: '\x1b[31' + }) + await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'mRED' }]) + + const restored = await new HistoryReader(dir).detectColdRestore(SESSION_ID) + + expect(restored?.snapshotAnsi).toContain('\x1b[31mRED') + }) +}) diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts index 2b8acbd2963..16bb7086bf6 100644 --- a/src/main/daemon/terminal-history-checkpoint-reader.ts +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -44,7 +44,10 @@ function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFi isNonNegativeSafeInteger(checkpoint.scrollbackLines) && (checkpoint.generation === undefined || isNonNegativeSafeInteger(checkpoint.generation)) && typeof checkpoint.checkpointedAt === 'string' && - (checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) + (checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) && + (checkpoint.pendingEscapeTailAnsi === undefined || + typeof checkpoint.pendingEscapeTailAnsi === 'string') && + (checkpoint.lastTitle === undefined || typeof checkpoint.lastTitle === 'string') ) } diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts index 8a68072a2da..fe71909f3b4 100644 --- a/src/main/daemon/terminal-history-cold-restore-info.ts +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -11,6 +11,8 @@ export type ColdRestoreInfo = { cols: number rows: number modes: TerminalModes + pendingEscapeTailAnsi?: string + lastTitle?: string } type RestoredSnapshot = { @@ -21,6 +23,8 @@ type RestoredSnapshot = { cols: number rows: number modes: TerminalModes + pendingEscapeTailAnsi?: string + lastTitle?: string } export function coldRestoreInfoFromSnapshot( @@ -39,6 +43,10 @@ export function coldRestoreInfoFromSnapshot( cwd: cwd ?? meta.cwd, cols: snapshot.cols, rows: snapshot.rows, - modes: snapshot.modes + modes: snapshot.modes, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}), + ...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}) } } diff --git a/src/main/daemon/terminal-history-file-limits.ts b/src/main/daemon/terminal-history-file-limits.ts index 5fc4523d785..207d918abbc 100644 --- a/src/main/daemon/terminal-history-file-limits.ts +++ b/src/main/daemon/terminal-history-file-limits.ts @@ -1,8 +1,5 @@ export const TERMINAL_HISTORY_META_MAX_BYTES = 64 * 1024 export const TERMINAL_HISTORY_LOG_MAX_BYTES = 5 * 1024 * 1024 -// Why deliberately generous: the checkpoint writer is unbounded, and a read cap under what it -// can emit silently drops ALL scrollback on cold restore. This guards a corrupt/runaway file, -// not retention — a 50k-row max preset of ordinary text serializes to ~14MB, ~15x under. Output -// colored per cell can still exceed this; trimming the snapshot writer-side is the real fix. +// Shared reader/writer contract: oversized snapshots trim their oldest rows before commit. export const TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES = 200_000_000 export const TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES = 16 * 1024 * 1024 diff --git a/src/main/daemon/terminal-history-manager-options.ts b/src/main/daemon/terminal-history-manager-options.ts index 7e03e856eb0..8f846ac9276 100644 --- a/src/main/daemon/terminal-history-manager-options.ts +++ b/src/main/daemon/terminal-history-manager-options.ts @@ -10,4 +10,7 @@ export type OpenSessionOptions = { export type HistoryManagerOptions = { onWriteError?: (sessionId: string, error: Error) => void + checkpointMaxBytes?: number } + +export type HistoryCheckpointResult = 'committed' | 'retryable' | 'unavailable' diff --git a/src/main/daemon/terminal-history-mutation-tracker.ts b/src/main/daemon/terminal-history-mutation-tracker.ts new file mode 100644 index 00000000000..80475f60535 --- /dev/null +++ b/src/main/daemon/terminal-history-mutation-tracker.ts @@ -0,0 +1,28 @@ +export class TerminalHistoryMutationTracker { + private pending = new Map>>() + + track(sessionId: string, operation: Promise): Promise { + const mutations = this.pending.get(sessionId) ?? new Set>() + mutations.add(operation) + this.pending.set(sessionId, mutations) + void operation.then( + () => this.finish(sessionId, operation), + () => this.finish(sessionId, operation) + ) + return operation + } + + async wait(sessionId: string): Promise { + while (this.pending.has(sessionId)) { + await Promise.allSettled(this.pending.get(sessionId)!) + } + } + + private finish(sessionId: string, operation: Promise): void { + const mutations = this.pending.get(sessionId) + mutations?.delete(operation) + if (mutations?.size === 0) { + this.pending.delete(sessionId) + } + } +} diff --git a/src/main/daemon/terminal-history-seed-chunks.test.ts b/src/main/daemon/terminal-history-seed-chunks.test.ts new file mode 100644 index 00000000000..79c65a2a597 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-chunks.test.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + iterateTerminalHistorySeedChunks, + measureTerminalHistorySeed, + TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS +} from './terminal-history-seed-chunks' + +describe('terminal history seed chunks', () => { + it('preserves segment order without splitting valid surrogate pairs', () => { + const segments = [ + `${'a'.repeat(TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS - 1)}\ud83d`, + '\ude00tail' + ] + const chunks = [...iterateTerminalHistorySeedChunks(segments)] + + expect(chunks.join('')).toBe(segments.join('')) + expect(chunks.every((chunk) => chunk.length <= TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS)).toBe( + true + ) + expect(chunks).toContain('😀') + }) + + it('measures the exact chunk count, code units, and UTF-16 digest', () => { + const segments = ['alpha', '😀', '\x1b[31mred'] + const metrics = measureTerminalHistorySeed(segments) + const expectedDigest = createHash('sha256') + .update(Buffer.from(segments.join(''), 'utf16le')) + .digest('hex') + + expect(metrics).toEqual({ + chunkCount: [...iterateTerminalHistorySeedChunks(segments)].length, + codeUnits: segments.join('').length, + sha256: expectedDigest + }) + }) +}) diff --git a/src/main/daemon/terminal-history-seed-chunks.ts b/src/main/daemon/terminal-history-seed-chunks.ts new file mode 100644 index 00000000000..91f6ab5a90f --- /dev/null +++ b/src/main/daemon/terminal-history-seed-chunks.ts @@ -0,0 +1,74 @@ +import { createHash } from 'node:crypto' + +export const TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS = 512 * 1024 +export const TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS = 1024 * 1024 + +export type TerminalHistorySeedMetrics = { + chunkCount: number + codeUnits: number + sha256: string +} + +function isHighSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xd800 && codeUnit <= 0xdbff +} + +function isLowSurrogate(codeUnit: number): boolean { + return codeUnit >= 0xdc00 && codeUnit <= 0xdfff +} + +export function* iterateTerminalHistorySeedChunks(segments: readonly string[]): Generator { + let trailingHighSurrogate = '' + + for (const segment of segments) { + let offset = 0 + if (trailingHighSurrogate) { + if (segment.length > 0 && isLowSurrogate(segment.charCodeAt(0))) { + yield trailingHighSurrogate + segment[0] + offset = 1 + } else { + yield trailingHighSurrogate + } + trailingHighSurrogate = '' + } + + while (offset < segment.length) { + let end = Math.min(segment.length, offset + TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS) + if ( + end < segment.length && + isHighSurrogate(segment.charCodeAt(end - 1)) && + isLowSurrogate(segment.charCodeAt(end)) + ) { + end -= 1 + } + if (end === segment.length && isHighSurrogate(segment.charCodeAt(end - 1))) { + trailingHighSurrogate = segment[end - 1] + end -= 1 + } + if (end > offset) { + yield segment.slice(offset, end) + } + offset = Math.max(end, offset + (end === offset ? 1 : 0)) + } + } + + if (trailingHighSurrogate) { + yield trailingHighSurrogate + } +} + +export function measureTerminalHistorySeed( + segments: readonly string[] +): TerminalHistorySeedMetrics { + const hash = createHash('sha256') + let chunkCount = 0 + let codeUnits = 0 + + for (const chunk of iterateTerminalHistorySeedChunks(segments)) { + hash.update(Buffer.from(chunk, 'utf16le')) + chunkCount += 1 + codeUnits += chunk.length + } + + return { chunkCount, codeUnits, sha256: hash.digest('hex') } +} diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts new file mode 100644 index 00000000000..78732e81931 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -0,0 +1,13 @@ +import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' + +export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] { + if (restoreInfo.modes.alternateScreen) { + const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi + return normalBuffer ? [normalBuffer] : [] + } + return [ + restoreInfo.rehydrateSequences, + restoreInfo.snapshotAnsi, + ...(restoreInfo.pendingEscapeTailAnsi ? [restoreInfo.pendingEscapeTailAnsi] : []) + ].filter((segment) => segment.length > 0) +} diff --git a/src/main/daemon/terminal-history-seed-transfer-protocol.ts b/src/main/daemon/terminal-history-seed-transfer-protocol.ts new file mode 100644 index 00000000000..ce3b3db5a56 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-transfer-protocol.ts @@ -0,0 +1,44 @@ +export type TerminalHistorySeedTransferManifest = { + chunkCount: number + codeUnits: number + sha256: string +} + +export type CreateOrAttachHistorySeedPayload = { + historySeed?: string + historySeedTransferId?: string +} + +export type StartHistorySeedTransferRequest = { + id: string + type: 'startHistorySeedTransfer' + payload: TerminalHistorySeedTransferManifest +} + +export type AppendHistorySeedTransferRequest = { + id: string + type: 'appendHistorySeedTransfer' + payload: { + transferId: string + index: number + data: string + } +} + +export type FinishHistorySeedTransferRequest = { + id: string + type: 'finishHistorySeedTransfer' + payload: { transferId: string } +} + +export type AbortHistorySeedTransferRequest = { + id: string + type: 'abortHistorySeedTransfer' + payload: { transferId: string } +} + +export type TerminalHistorySeedTransferRequest = + | StartHistorySeedTransferRequest + | AppendHistorySeedTransferRequest + | FinishHistorySeedTransferRequest + | AbortHistorySeedTransferRequest diff --git a/src/main/daemon/terminal-history-seed-transfer-registry.test.ts b/src/main/daemon/terminal-history-seed-transfer-registry.test.ts new file mode 100644 index 00000000000..a7e0bd63657 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-transfer-registry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + iterateTerminalHistorySeedChunks, + measureTerminalHistorySeed +} from './terminal-history-seed-chunks' +import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' + +describe('TerminalHistorySeedTransferRegistry', () => { + it('validates and consumes an owner-bound completed transfer', () => { + const registry = new TerminalHistorySeedTransferRegistry() + const segments = ['first', '😀', 'last'] + const manifest = measureTerminalHistorySeed(segments) + const transferId = registry.start('owner-a', manifest) + const chunks = [...iterateTerminalHistorySeedChunks(segments)] + chunks.forEach((data, index) => registry.append('owner-a', transferId, index, data)) + registry.finish('owner-a', transferId) + + expect(() => registry.take('owner-b', transferId)).toThrow('not found') + expect(registry.take('owner-a', transferId).join('')).toBe(segments.join('')) + expect(() => registry.take('owner-a', transferId)).toThrow('not found') + }) + + it('rejects out-of-order and over-budget chunks', () => { + const segments = ['😀', 'a'] + const manifest = measureTerminalHistorySeed(segments) + const registry = new TerminalHistorySeedTransferRegistry(4) + const transferId = registry.start('owner', manifest) + + expect(() => registry.append('owner', transferId, 1, 'a')).toThrow('sequence mismatch') + registry.append('owner', transferId, 0, '😀') + expect(() => registry.append('owner', transferId, 1, 'a')).toThrow('retained byte limit') + }) + + it('rejects a digest mismatch and releases the transfer', () => { + const registry = new TerminalHistorySeedTransferRegistry() + const transferId = registry.start('owner', { + chunkCount: 1, + codeUnits: 4, + sha256: '0'.repeat(64) + }) + registry.append('owner', transferId, 0, 'test') + + expect(() => registry.finish('owner', transferId)).toThrow('digest mismatch') + expect(() => registry.take('owner', transferId)).toThrow('not found') + }) +}) diff --git a/src/main/daemon/terminal-history-seed-transfer-registry.ts b/src/main/daemon/terminal-history-seed-transfer-registry.ts new file mode 100644 index 00000000000..339762828b5 --- /dev/null +++ b/src/main/daemon/terminal-history-seed-transfer-registry.ts @@ -0,0 +1,166 @@ +import { createHash, randomUUID } from 'node:crypto' +import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' +import { TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS } from './terminal-history-seed-chunks' +import type { TerminalHistorySeedTransferManifest } from './terminal-history-seed-transfer-protocol' + +const MAX_TRANSFERS = 8 +const MAX_CHUNKS = 4096 +const TRANSFER_TTL_MS = 30_000 + +type Transfer = { + ownerId: string + manifest: TerminalHistorySeedTransferManifest + chunks: string[] + codeUnits: number + utf8Bytes: number + hash: ReturnType + finished: boolean + timer: ReturnType +} + +export class TerminalHistorySeedTransferRegistry { + private transfers = new Map() + private retainedBytes = 0 + + constructor( + private readonly maxRetainedBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES, + private readonly transferTtlMs = TRANSFER_TTL_MS + ) {} + + start(ownerId: string, manifest: TerminalHistorySeedTransferManifest): string { + this.validateManifest(manifest) + if (this.transfers.size >= MAX_TRANSFERS) { + throw new Error('Too many pending terminal history seed transfers') + } + const transferId = randomUUID() + const timer = setTimeout(() => this.delete(transferId), this.transferTtlMs) + timer.unref() + this.transfers.set(transferId, { + ownerId, + manifest: { ...manifest }, + chunks: [], + codeUnits: 0, + utf8Bytes: 0, + hash: createHash('sha256'), + finished: false, + timer + }) + return transferId + } + + append(ownerId: string, transferId: string, index: number, data: string): void { + const transfer = this.getOwned(ownerId, transferId) + if (transfer.finished) { + throw new Error('Terminal history seed transfer is already finished') + } + if (index !== transfer.chunks.length || index >= transfer.manifest.chunkCount) { + throw new Error('Terminal history seed chunk sequence mismatch') + } + if (data.length === 0 || data.length > TERMINAL_HISTORY_SEED_CHUNK_CODE_UNITS) { + throw new Error('Terminal history seed chunk size is invalid') + } + const utf8Bytes = Buffer.byteLength(data, 'utf8') + if ( + transfer.codeUnits + data.length > transfer.manifest.codeUnits || + this.retainedBytes + utf8Bytes > this.maxRetainedBytes + ) { + throw new Error('Terminal history seed transfer exceeds retained byte limit') + } + transfer.chunks.push(data) + transfer.codeUnits += data.length + transfer.utf8Bytes += utf8Bytes + transfer.hash.update(Buffer.from(data, 'utf16le')) + this.retainedBytes += utf8Bytes + this.refreshExpiry(transferId, transfer) + } + + finish(ownerId: string, transferId: string): void { + const transfer = this.getOwned(ownerId, transferId) + if (transfer.finished) { + throw new Error('Terminal history seed transfer is already finished') + } + if ( + transfer.chunks.length !== transfer.manifest.chunkCount || + transfer.codeUnits !== transfer.manifest.codeUnits + ) { + throw new Error('Terminal history seed transfer is incomplete') + } + const digest = transfer.hash.digest('hex') + if (digest !== transfer.manifest.sha256) { + this.delete(transferId) + throw new Error('Terminal history seed transfer digest mismatch') + } + transfer.finished = true + this.refreshExpiry(transferId, transfer) + } + + take(ownerId: string, transferId: string): readonly string[] { + const transfer = this.getOwned(ownerId, transferId) + if (!transfer.finished) { + throw new Error('Terminal history seed transfer is not finished') + } + const chunks = transfer.chunks + this.delete(transferId) + return chunks + } + + abort(ownerId: string, transferId: string): void { + this.getOwned(ownerId, transferId) + this.delete(transferId) + } + + clearOwner(ownerId: string): void { + for (const [transferId, transfer] of this.transfers) { + if (transfer.ownerId === ownerId) { + this.delete(transferId) + } + } + } + + dispose(): void { + for (const transferId of this.transfers.keys()) { + this.delete(transferId) + } + } + + private validateManifest(manifest: TerminalHistorySeedTransferManifest): void { + // Why codeUnits is compared to a byte cap: UTF-8 never encodes a UTF-16 code unit in under one byte, + // so codeUnits > maxRetainedBytes proves the payload cannot fit. Scaling up for multibyte would + // reject ASCII seeds that do fit; append() enforces the real byte budget. + if ( + !Number.isInteger(manifest.chunkCount) || + manifest.chunkCount < 1 || + manifest.chunkCount > MAX_CHUNKS || + !Number.isInteger(manifest.codeUnits) || + manifest.codeUnits < 1 || + manifest.codeUnits > this.maxRetainedBytes || + !/^[a-f0-9]{64}$/.test(manifest.sha256) + ) { + throw new Error('Terminal history seed transfer manifest is invalid') + } + } + + private getOwned(ownerId: string, transferId: string): Transfer { + const transfer = this.transfers.get(transferId) + if (!transfer || transfer.ownerId !== ownerId) { + throw new Error('Terminal history seed transfer not found') + } + return transfer + } + + private refreshExpiry(transferId: string, transfer: Transfer): void { + clearTimeout(transfer.timer) + transfer.timer = setTimeout(() => this.delete(transferId), this.transferTtlMs) + transfer.timer.unref() + } + + private delete(transferId: string): void { + const transfer = this.transfers.get(transferId) + if (!transfer) { + return + } + clearTimeout(transfer.timer) + this.retainedBytes -= transfer.utf8Bytes + this.transfers.delete(transferId) + } +} diff --git a/src/main/daemon/terminal-history-session-writer.ts b/src/main/daemon/terminal-history-session-writer.ts index 1af964e2c05..644f247a1f1 100644 --- a/src/main/daemon/terminal-history-session-writer.ts +++ b/src/main/daemon/terminal-history-session-writer.ts @@ -15,7 +15,9 @@ import { } from './terminal-history-log' import type { SessionMeta } from './terminal-history-metadata' import { clearTerminalHistoryRecoveryProtection } from './terminal-history-recovery-quarantine' -import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types' +import type { PendingOutputRecord, TerminalSnapshot } from './types' +import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' +import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer' // Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log. const LOG_MAX_BYTES = 5 * 1024 * 1024 @@ -28,7 +30,8 @@ export class TerminalHistorySessionWriter { constructor( readonly dir: string, - fresh: boolean + fresh: boolean, + private readonly checkpointMaxBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES ) { this.checkpointPath = join(dir, 'checkpoint.json') this.logPath = join(dir, 'output.log') @@ -55,31 +58,38 @@ export class TerminalHistorySessionWriter { return 'ok' } - async checkpoint(snapshot: TerminalSnapshot): Promise { + async checkpoint( + snapshot: TerminalSnapshot + ): Promise<{ result: 'committed' } | { result: 'retryable'; error: Error }> { // Why: snapshot.cwd is null until OSC-7; preserve meta.json's usable cwd for cold restore. const effectiveCwd = snapshot.cwd ?? this.readMeta()?.cwd ?? null this.resolveLogState() const generation = (this.logGeneration ?? 0) + 1 - const checkpointFile: TerminalCheckpointFile = { - snapshotAnsi: snapshot.snapshotAnsi, - scrollbackAnsi: snapshot.scrollbackAnsi, - oscLinks: snapshot.oscLinks, - rehydrateSequences: snapshot.rehydrateSequences, - cwd: effectiveCwd, - cols: snapshot.cols, - rows: snapshot.rows, - modes: snapshot.modes, - scrollbackLines: snapshot.scrollbackLines, - generation, - checkpointedAt: new Date().toISOString() + let data: string + try { + data = await serializeTerminalCheckpointWithinLimit( + snapshot, + { + cwd: effectiveCwd, + generation, + checkpointedAt: new Date().toISOString() + }, + this.checkpointMaxBytes + ) + } catch (error) { + return { + result: 'retryable', + error: error instanceof Error ? error : new Error(String(error)) + } } const tmpPath = `${this.checkpointPath}.tmp` - await fsPromises.writeFile(tmpPath, JSON.stringify(checkpointFile)) + await fsPromises.writeFile(tmpPath, data) await fsPromises.rename(tmpPath, this.checkpointPath) await fsPromises.writeFile(this.logPath, encodeLogHeader(generation)) this.logGeneration = generation this.logBytes = LOG_HEADER_BYTES clearTerminalHistoryRecoveryProtection(this.dir) + return { result: 'committed' } } // Why: a warm writer must append to the existing generation without clobbering its log. diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index e98144e516f..3a3e9b1c904 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -25,7 +25,7 @@ export type CreateOrAttachOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number - historySeed?: string + historySeedChunks?: readonly string[] startupIngress?: PtyStartupIngressIntent agentSessionEnsure?: { claim: AgentSessionExecutionClaim diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index f61fb25d798..dcc88fada05 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -107,7 +107,7 @@ export async function createOrAttachTerminalSession( wslDistro }), shellReadySupported, - historySeed: opts.historySeed, + historySeedChunks: opts.historySeedChunks, ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), wslDistro, onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 53fc791bb05..bc0be4070d7 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -19,6 +19,7 @@ import type { AgentSessionOwnerBinding, AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' +import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' export type { TerminalModes } from './terminal-modes' import type { TerminalSnapshot } from './terminal-snapshot' export type { TerminalSnapshot } from './terminal-snapshot' @@ -57,7 +58,7 @@ export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daem export type CreateOrAttachRequest = { id: string type: 'createOrAttach' - payload: { + payload: HistorySeedProtocol.CreateOrAttachHistorySeedPayload & { sessionId: string cols: number rows: number @@ -82,8 +83,6 @@ export type CreateOrAttachRequest = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number - /** Recovered ANSI applied before the new subprocess can emit startup output. */ - historySeed?: string startupIngress?: PtyStartupIngressIntent agentSessionEnsure?: { claim: AgentSessionExecutionClaim @@ -101,9 +100,7 @@ export type CloseStartupQueryAuthorityRequest = { export type CancelCreateOrAttachRequest = { id: string type: 'cancelCreateOrAttach' - payload: { - sessionId: string - } + payload: { sessionId: string } } export type WriteRequest = { @@ -295,6 +292,7 @@ export type TakePendingOutputResult = { export type DaemonRequest = | CreateOrAttachRequest + | HistorySeedProtocol.TerminalHistorySeedTransferRequest | CancelCreateOrAttachRequest | WriteRequest | ResizeRequest diff --git a/src/main/git/porcelain-v1-records.test.ts b/src/main/git/porcelain-v1-records.test.ts new file mode 100644 index 00000000000..396788b2e17 --- /dev/null +++ b/src/main/git/porcelain-v1-records.test.ts @@ -0,0 +1,94 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parsePorcelainV1Records } from './porcelain-v1-records' + +const tempRoots: string[] = [] + +const git = (args: string[], cwd: string): string => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + +function createRepo(): string { + const repo = mkdtempSync(join(tmpdir(), 'orca-porcelain-v1-')) + tempRoots.push(repo) + git(['init', '-q', '-b', 'main'], repo) + git(['config', 'user.email', 'test@example.com'], repo) + git(['config', 'user.name', 'Test'], repo) + return repo +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('parsePorcelainV1Records', () => { + it('returns [] for a clean status', () => { + expect(parsePorcelainV1Records('')).toEqual([]) + }) + + it('keeps paths containing spaces and quotes intact', () => { + const repo = createRepo() + writeFileSync(join(repo, 'a file "quoted".txt'), 'x') + + const records = parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo)) + + expect(records).toEqual([{ xy: '??', path: 'a file "quoted".txt' }]) + }) + + it('keeps non-ASCII paths raw rather than C-quoted', () => { + const repo = createRepo() + writeFileSync(join(repo, '日本語.txt'), 'x') + + expect(parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo))).toEqual([ + { xy: '??', path: '日本語.txt' } + ]) + }) + + // Why: a rename emits its ORIGIN as a second NUL field. Reading that origin as + // its own record would invent a status code from the first bytes of a path — + // and `?? ` is exactly what the shared-symlink filter keys on. + it('consumes the origin path of a rename instead of emitting it as a record', () => { + const repo = createRepo() + writeFileSync(join(repo, 'original.txt'), 'content\n') + git(['add', '-A'], repo) + git(['commit', '-qm', 'init'], repo) + git(['mv', 'original.txt', 'renamed.txt'], repo) + + const records = parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo)) + + expect(records).toEqual([{ xy: 'R ', path: 'renamed.txt' }]) + }) + + it('reports a rename alongside a later untracked entry', () => { + const repo = createRepo() + writeFileSync(join(repo, 'original.txt'), 'content\n') + git(['add', '-A'], repo) + git(['commit', '-qm', 'init'], repo) + git(['mv', 'original.txt', 'renamed.txt'], repo) + mkdirSync(join(repo, 'node_modules')) + writeFileSync(join(repo, 'node_modules', 'pkg.js'), 'x') + + const records = parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo)) + + expect(records).toEqual([ + { xy: 'R ', path: 'renamed.txt' }, + { xy: '??', path: 'node_modules/' } + ]) + }) + + it('parses staged and unstaged codes distinctly', () => { + const repo = createRepo() + writeFileSync(join(repo, 'tracked.txt'), 'v1\n') + git(['add', '-A'], repo) + git(['commit', '-qm', 'init'], repo) + writeFileSync(join(repo, 'tracked.txt'), 'v2\n') + + expect(parsePorcelainV1Records(git(['status', '--porcelain', '-z'], repo))).toEqual([ + { xy: ' M', path: 'tracked.txt' } + ]) + }) +}) diff --git a/src/main/git/porcelain-v1-records.ts b/src/main/git/porcelain-v1-records.ts new file mode 100644 index 00000000000..d8836df612c --- /dev/null +++ b/src/main/git/porcelain-v1-records.ts @@ -0,0 +1,36 @@ +/** One `git status --porcelain -z` record: the two-letter status code and the + * path it applies to. Rename/copy origins are consumed, not reported. */ +export type PorcelainV1Record = { + xy: string + path: string +} + +/** Parse `git status --porcelain -z` (v1) into records. + * + * Why `-z` and a real parser rather than splitting lines: without `-z` Git + * quotes and escapes paths containing spaces, quotes, or non-ASCII bytes, so a + * path comparison against a configured entry would silently miss. With `-z` + * paths are raw, but a rename or copy emits its origin as a *second* + * NUL-separated field — treating that origin as its own record would invent a + * status code out of the leading bytes of a path. */ +export function parsePorcelainV1Records(stdout: string): PorcelainV1Record[] { + const fields = stdout.split('\0') + const records: PorcelainV1Record[] = [] + + for (let index = 0; index < fields.length; index++) { + const field = fields[index] + // Why: the trailing NUL yields a final empty field; a record is always + // `XY`, so anything shorter cannot be one. + if (field.length < 4) { + continue + } + const xy = field.slice(0, 2) + records.push({ xy, path: field.slice(3) }) + if (xy.includes('R') || xy.includes('C')) { + // Skip the origin path that follows a rename or copy. + index++ + } + } + + return records +} diff --git a/src/main/git/status-shared-symlinks.test.ts b/src/main/git/status-shared-symlinks.test.ts new file mode 100644 index 00000000000..44bef357a19 --- /dev/null +++ b/src/main/git/status-shared-symlinks.test.ts @@ -0,0 +1,147 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getStatus } from './status' + +// Why: `node_modules/` is a directory-only ignore rule. It matches the primary +// checkout's real directory but never the worktree's symlink, so Git reports the +// link as untracked forever — a phantom row in the diff and a permanently dirty +// worktree. Status has to drop it; nothing else can. +const git = (args: string[], cwd: string): string => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + +describe('getStatus shared symlink exclusion', () => { + let root: string + let primary: string + let worktree: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-status-shared-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary) + git(['init', '-q', '-b', 'main'], primary) + git(['config', 'user.email', 'test@example.com'], primary) + git(['config', 'user.name', 'Test'], primary) + writeFileSync(join(primary, '.gitignore'), 'node_modules/\n') + writeFileSync(join(primary, 'README.md'), '# tracked\n') + writeFileSync(join(primary, 'OTHER.md'), '# other\n') + symlinkSync('README.md', join(primary, 'tracked-link')) + git(['add', '-A'], primary) + git(['commit', '-qm', 'init'], primary) + mkdirSync(join(primary, 'node_modules')) + git(['worktree', 'add', '-q', worktree, '-b', 'feature'], primary) + symlinkSync(join(primary, 'node_modules'), join(worktree, 'node_modules'), 'dir') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + // Why: guards the premise of the whole fix. If Git ever reported the symlink + // as `node_modules/` (as it does for a real untracked directory) the path + // comparison below would silently miss and the exclusion would do nothing. + it('Git reports the shared symlink as untracked, without a trailing slash', async () => { + const status = await getStatus(worktree) + + expect(status.entries).toEqual([ + expect.objectContaining({ path: 'node_modules', area: 'untracked' }) + ]) + }) + + it('drops the shared symlink when it is declared shared', async () => { + const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] }) + + expect(status.entries).toEqual([]) + }) + + // The negative control that matters: real work must never be hidden. + it('still reports a genuine untracked file alongside a shared symlink', async () => { + writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work\n') + + const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] }) + + expect(status.entries).toEqual([ + expect.objectContaining({ path: 'scratch.txt', area: 'untracked' }) + ]) + }) + + it('still reports a modified tracked file alongside a shared symlink', async () => { + writeFileSync(join(worktree, 'README.md'), '# edited\n') + + const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] }) + + expect(status.entries).toEqual([ + expect.objectContaining({ path: 'README.md', area: 'unstaged' }) + ]) + }) + + // Why: the configured name alone must not hide anything — only a real symlink. + // `vendor` is not gitignored, so Git genuinely reports it and the filter is + // the only thing that could wrongly drop it. + it('keeps a regular directory the user created at a configured shared name', async () => { + mkdirSync(join(worktree, 'vendor')) + writeFileSync(join(worktree, 'vendor', 'real.txt'), 'user work\n') + + const status = await getStatus(worktree, { + sharedLinkPaths: ['node_modules', 'vendor'] + }) + + expect(status.entries).toEqual([ + expect.objectContaining({ path: 'vendor/real.txt', area: 'untracked' }) + ]) + }) + + // Why: the exact discriminator for "configured AND really a symlink". The path + // matches a declared name exactly, so only the symlink check keeps the user's + // file visible. + it('keeps a regular file the user created at a configured shared name', async () => { + writeFileSync(join(worktree, 'notes'), 'user work\n') + + const status = await getStatus(worktree, { + sharedLinkPaths: ['node_modules', 'notes'] + }) + + expect(status.entries).toEqual([expect.objectContaining({ path: 'notes', area: 'untracked' })]) + }) + + // Why: only *untracked* entries are Orca's artifacts. A symlink Git tracks is + // versioned content, so an edit to it is the user's work even when the path is + // declared shared — dropping it would hide a committable change. + it('keeps a modified tracked symlink at a declared shared path', async () => { + unlinkSync(join(worktree, 'tracked-link')) + symlinkSync('OTHER.md', join(worktree, 'tracked-link')) + + const status = await getStatus(worktree, { + sharedLinkPaths: ['node_modules', 'tracked-link'] + }) + + expect(status.entries).toEqual([ + expect.objectContaining({ path: 'tracked-link', area: 'unstaged' }) + ]) + }) + + // Why: these are the names a byte-for-byte path comparison is most likely to + // get wrong — a space breaks naive whitespace splitting, and non-ASCII is what + // Git C-quotes unless the reader opts out. + it('drops shared symlinks whose names have a space or non-ASCII characters', async () => { + const names = ['my shared dir', 'ライブラリ'] + for (const name of names) { + symlinkSync(join(primary, 'node_modules'), join(worktree, name), 'dir') + } + + const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules', ...names] }) + + expect(status.entries).toEqual([]) + }) + + it('keeps a symlink at a path that was never declared shared', async () => { + symlinkSync(join(primary, 'node_modules'), join(worktree, 'vendor'), 'dir') + + const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] }) + + expect(status.entries).toEqual([expect.objectContaining({ path: 'vendor', area: 'untracked' })]) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ece3c53b2cf..98d04ec93b9 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -38,6 +38,7 @@ import { gitStreamStdout } from './runner' import { StatusPorcelainParser } from '../../shared/git-status-porcelain-parser' +import { findExistingWorktreeSymlinkPaths } from './worktree-symlink-detection' import { capGitStatusEntries, resolveGitStatusLimit } from '../../shared/git-status-limit' import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from './max-buffer-overflow' import { @@ -199,6 +200,12 @@ export type GetStatusOptions = GitRuntimeOptions & { */ limit?: number bypassEffectiveUpstreamNegativeCache?: boolean + /** Paths Orca may have symlinked into this worktree (per-user shared paths + * plus `orca.yaml` shared directories). Untracked entries that are one of + * these *and* really symlinks are dropped: Git cannot ignore them when the + * repo's rule is directory-only (`node_modules/`), but they are Orca's own + * artifacts, not user work. */ + sharedLinkPaths?: readonly string[] } /** @@ -239,10 +246,44 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.includeIgnored === true, options.reuseLineStats === true, options.bypassEffectiveUpstreamNegativeCache === true, - limit + limit, + // Why: this changes which entries survive, so it must not share a cache slot. + (options.sharedLinkPaths ?? []).join('\u0001') ].join('\0') } +/** Remove untracked entries that are shared symlinks Orca created. + * + * Why this can't be left to Git: a directory-only ignore rule (`node_modules/`) + * matches the primary checkout's real directory but never the worktree's + * symlink, so Git reports it untracked forever — a phantom row in the diff and + * a permanently "dirty" worktree. + * + * Tight on both axes: an entry must be configured as shared *and* actually be a + * symlink. A regular file the user created at a configured name still shows up, + * and so does a symlink at a path nobody declared shared. Mutates `entries`. */ +async function dropSharedSymlinkUntrackedEntries( + worktreePath: string, + entries: GitStatusEntry[], + sharedLinkPaths: readonly string[] +): Promise { + // Why: a clean tree has no untracked entries, so this costs nothing on the + // common status-poll path — no syscall, no config read, no subprocess. + if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) { + return + } + const sharedLinks = new Set(await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths)) + if (sharedLinks.size === 0) { + return + } + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index] + if (entry.area === 'untracked' && sharedLinks.has(entry.path)) { + entries.splice(index, 1) + } + } +} + async function runGetStatus( worktreePath: string, options: GetStatusOptions = {} @@ -315,6 +356,8 @@ async function runGetStatus( } } + await dropSharedSymlinkUntrackedEntries(worktreePath, entries, options.sharedLinkPaths ?? []) + if (statusSucceeded && !didHitLimit && shouldProbeEffectiveUpstreamStatus(branch, upstreamName)) { const branchName = getShortBranchName(branch) if (branchName) { diff --git a/src/main/git/worktree-shared-directories.test.ts b/src/main/git/worktree-shared-directories.test.ts new file mode 100644 index 00000000000..97671c88fa6 --- /dev/null +++ b/src/main/git/worktree-shared-directories.test.ts @@ -0,0 +1,347 @@ +import { execFileSync } from 'node:child_process' +import { lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { devNull, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearConfiguredWorktreeSharedDirectoriesCacheForTests, + getConfiguredWorktreeSharedDirectories, + getWorktreeSharedLinkPaths, + resolveWorktreeSharedDirectories +} from './worktree-shared-directories' +import { + createWorktreeSharedPaths, + findExistingWorktreeSymlinkPaths +} from '../ipc/worktree-symlinks' +import { assertWorktreeCleanForRemoval } from './worktree' +import { getStatus } from './status' + +const git = (args: string[], cwd: string): void => { + execFileSync('git', args, { + cwd, + stdio: 'ignore', + env: { + ...process.env, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_SYSTEM: devNull + } + }) +} + +describe('resolveWorktreeSharedDirectories', () => { + let repo: string + let warn: ReturnType + + const writeOrcaYaml = (body: string): void => { + writeFileSync(join(repo, 'orca.yaml'), body) + } + + beforeEach(() => { + clearConfiguredWorktreeSharedDirectoriesCacheForTests() + repo = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-')) + git(['init', '-q'], repo) + git(['config', 'user.email', 'test@example.com'], repo) + git(['config', 'user.name', 'Test'], repo) + writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\n') + writeFileSync(join(repo, 'README.md'), '# tracked\n') + git(['add', '.gitignore', 'README.md'], repo) + git(['commit', '-qm', 'init'], repo) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + rmSync(repo, { recursive: true, force: true }) + }) + + it('returns gitignored directories listed under worktree.sharedDirectories', async () => { + mkdirSync(join(repo, 'node_modules')) + mkdirSync(join(repo, '.cache')) + writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n - .cache\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['.cache', 'node_modules']) + }) + + it('returns [] when orca.yaml is absent', async () => { + mkdirSync(join(repo, 'node_modules')) + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) + }) + + it('returns [] when orca.yaml has no worktree key', async () => { + mkdirSync(join(repo, 'node_modules')) + writeOrcaYaml('scripts:\n setup: pnpm install\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) + }) + + it('skips a directory that is not gitignored', async () => { + mkdirSync(join(repo, 'shared-but-tracked')) + writeOrcaYaml('worktree:\n sharedDirectories:\n - shared-but-tracked\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('only gitignored directories')) + }) + + it('skips a listed path that is a file, not a directory', async () => { + writeFileSync(join(repo, '.cache'), 'not a dir') + writeOrcaYaml('worktree:\n sharedDirectories:\n - .cache\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be directories')) + }) + + it('skips entries that are absent from the primary checkout', async () => { + writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) + }) + + it('drops unsafe entries before touching the filesystem', async () => { + mkdirSync(join(repo, 'node_modules')) + writeOrcaYaml( + [ + 'worktree:', + ' sharedDirectories:', + ' - ../escape', + ' - /etc', + ' - .git', + ' - .git/hooks', + ' - node_modules', + '' + ].join('\n') + ) + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules']) + }) + + it('normalizes trailing slashes, ./ prefixes and duplicates', async () => { + mkdirSync(join(repo, 'node_modules')) + writeOrcaYaml( + 'worktree:\n sharedDirectories:\n - node_modules/\n - ./node_modules\n - node_modules\n' + ) + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules']) + }) + + it('returns [] for a malformed sharedDirectories value instead of throwing', async () => { + mkdirSync(join(repo, 'node_modules')) + writeOrcaYaml('worktree:\n sharedDirectories: node_modules\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual([]) + }) + + it('resolves nested directories anchored at the repo root', async () => { + mkdirSync(join(repo, 'apps', 'web', '.cache'), { recursive: true }) + writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\napps/web/.cache\n') + writeOrcaYaml('worktree:\n sharedDirectories:\n - apps/web/.cache\n') + + expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['apps/web/.cache']) + }) +}) + +describe('getConfiguredWorktreeSharedDirectories', () => { + let repo: string + + beforeEach(() => { + clearConfiguredWorktreeSharedDirectoriesCacheForTests() + repo = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-config-')) + }) + + afterEach(() => { + rmSync(repo, { recursive: true, force: true }) + }) + + it('returns the configured names without existence or gitignore filtering', () => { + // Why: neither directory exists, yet removal still needs both names to + // recognize and unlink the symlinks a previous creation left behind. + writeFileSync( + join(repo, 'orca.yaml'), + 'worktree:\n sharedDirectories:\n - node_modules\n - .cache\n' + ) + + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules', '.cache']) + }) + + it('combines live per-user paths with cached repo configuration', () => { + writeFileSync(join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - node_modules\n') + + expect(getWorktreeSharedLinkPaths({ path: repo, symlinkPaths: ['.cache'] })).toEqual([ + '.cache', + 'node_modules' + ]) + }) + + it('returns [] when orca.yaml is absent or has no worktree key', () => { + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([]) + + writeFileSync(join(repo, 'orca.yaml'), 'scripts:\n setup: pnpm install\n') + clearConfiguredWorktreeSharedDirectoriesCacheForTests() + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([]) + }) + + it('caches repeated status polls but refreshes changed configuration', () => { + vi.useFakeTimers() + try { + writeFileSync( + join(repo, 'orca.yaml'), + 'worktree:\n sharedDirectories:\n - node_modules\n' + ) + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules']) + + writeFileSync(join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - .cache\n') + + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules']) + vi.advanceTimersByTime(30_001) + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['.cache']) + } finally { + vi.useRealTimers() + } + }) +}) + +// Why: `node_modules/` is a directory-only ignore rule. It matches the primary's +// real directory, so the shared directory resolves, but never the worktree's +// symlink — Git reports that link as untracked and refuses a non-force removal +// unless deletion is told to tolerate it. +describe('shared directories and worktree removal', () => { + let root: string + let primary: string + let worktree: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-removal-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary) + git(['init', '-q', '-b', 'main'], primary) + git(['config', 'user.email', 'test@example.com'], primary) + git(['config', 'user.name', 'Test'], primary) + writeFileSync(join(primary, '.gitignore'), 'node_modules/\n') + writeFileSync( + join(primary, 'orca.yaml'), + 'worktree:\n sharedDirectories:\n - node_modules\n' + ) + git(['add', '-A'], primary) + git(['commit', '-qm', 'init'], primary) + mkdirSync(join(primary, 'node_modules')) + git(['worktree', 'add', '-q', worktree, '-b', 'feature'], primary) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('leaves a worktree removable without force after sharing a directory', async () => { + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true) + + const ignoredLinkedPaths = await findExistingWorktreeSymlinkPaths( + worktree, + getConfiguredWorktreeSharedDirectories(primary) + ) + + expect(ignoredLinkedPaths).toEqual(['node_modules']) + await expect( + assertWorktreeCleanForRemoval(worktree, false, { ignoredUntrackedPaths: ignoredLinkedPaths }) + ).resolves.toBeUndefined() + }) + + // Why: the halves are tested apart — resolver output here, a hardcoded + // `['node_modules']` in the status tests. This pins the seam between them, so a + // resolver that ever returns a differently-spelled path can't leave the link + // showing as a phantom untracked row. + it('leaves status clean when the resolved directory is fed back as a shared link', async () => { + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + + const status = await getStatus(worktree, { + sharedLinkPaths: getWorktreeSharedLinkPaths({ path: primary }) + }) + + expect(status.entries).toEqual([]) + }) + + // Why: `-z` output is NUL-delimited and `.trim()` leaves interior NULs, so the + // raw stdout reached the user as `?? node_modules?? precious.txt` — + // raw control bytes in a message, listing the very link this feature exists to + // suppress. The error must name only what the user can actually act on. + it('reports only genuine blockers in the removal error, with no NUL bytes', async () => { + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + writeFileSync(join(worktree, 'precious.txt'), 'unsaved work') + + const removal = assertWorktreeCleanForRemoval(worktree, false, { + ignoredUntrackedPaths: await findExistingWorktreeSymlinkPaths( + worktree, + getConfiguredWorktreeSharedDirectories(primary) + ) + }) + + await expect(removal).rejects.toThrow('uncommitted or untracked') + // Why an exact match: it proves both halves at once — no interior NUL, and + // the tolerated `node_modules` link absent from what the user is told to fix. + await expect(removal).rejects.toMatchObject({ stdout: '?? precious.txt' }) + }) + + // Why `-z` is used at all: Git C-quotes non-ASCII paths under `--porcelain`, + // so a byte-for-byte comparison against the configured entry would miss and the + // link would read as a blocker. A space alone is not quoted but is the case a + // naive whitespace split would break. + it('tolerates shared directories whose names have a space or non-ASCII characters', async () => { + const names = ['my shared dir', 'ライブラリ'] + for (const name of names) { + mkdirSync(join(primary, name)) + } + writeFileSync(join(primary, '.gitignore'), `node_modules/\n${names.join('\n')}\n`) + writeFileSync( + join(primary, 'orca.yaml'), + `worktree:\n sharedDirectories:\n${names.map((name) => ` - ${name}`).join('\n')}\n` + ) + clearConfiguredWorktreeSharedDirectoriesCacheForTests() + + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + + const ignoredLinkedPaths = await findExistingWorktreeSymlinkPaths( + worktree, + getConfiguredWorktreeSharedDirectories(primary) + ) + + expect(ignoredLinkedPaths).toEqual(expect.arrayContaining(names)) + await expect( + assertWorktreeCleanForRemoval(worktree, false, { ignoredUntrackedPaths: ignoredLinkedPaths }) + ).resolves.toBeUndefined() + }) + + it('still refuses removal for real untracked changes next to a shared directory', async () => { + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work') + + await expect( + assertWorktreeCleanForRemoval(worktree, false, { + ignoredUntrackedPaths: await findExistingWorktreeSymlinkPaths( + worktree, + getConfiguredWorktreeSharedDirectories(primary) + ) + }) + ).rejects.toThrow('uncommitted or untracked') + }) +}) diff --git a/src/main/git/worktree-shared-directories.ts b/src/main/git/worktree-shared-directories.ts new file mode 100644 index 00000000000..6ad9e6096ed --- /dev/null +++ b/src/main/git/worktree-shared-directories.ts @@ -0,0 +1,111 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import { checkIgnoredPaths } from './check-ignored-paths' +import type { GitRuntimeOptions } from './git-runtime-options' +import { loadHooks } from '../hooks' +import type { Repo } from '../../shared/types' + +// Why: a fresh worktree has no node_modules/.cache, and copying them is slow and +// duplicates disk; `orca.yaml` names the ones every worktree should share instead. + +const CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS = 30_000 +const configuredSharedDirectoriesByRepoPath = new Map< + string, + { directories: string[]; expiresAt: number } +>() + +/** The configured `worktree.sharedDirectories` names, before any existence or + * gitignore filtering. + * + * Why deletion can't reuse the resolver below: a directory-only ignore rule + * (`node_modules/`) matches the primary's real directory but never the + * worktree's symlink, so Git reports that symlink as untracked. Removal has to + * tolerate and unlink it, and the resolver would have already dropped it. + * + * `readonly` because this is the cached array itself: a mutating caller would + * corrupt every later read for the rest of the TTL. Copying on return would fix + * that too, but this runs on the status-polling path — the type costs nothing. */ +export function getConfiguredWorktreeSharedDirectories(repoPath: string): readonly string[] { + const cached = configuredSharedDirectoriesByRepoPath.get(repoPath) + const now = Date.now() + if (cached && cached.expiresAt > now) { + return cached.directories + } + const configured = loadHooks(repoPath)?.worktree?.sharedDirectories ?? [] + configuredSharedDirectoriesByRepoPath.set(repoPath, { + directories: configured, + expiresAt: now + CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS + }) + return configured +} + +/** Reset the process cache between tests. */ +export function clearConfiguredWorktreeSharedDirectoriesCacheForTests(): void { + configuredSharedDirectoriesByRepoPath.clear() +} + +/** Every path Orca may have symlinked into a worktree: the per-user Worktree + * Shared Paths setting plus the repo's `orca.yaml` shared directories. + * + * Callers pair this with `findExistingWorktreeSymlinkPaths`, which keeps only + * the entries that really are symlinks — so a configured name that the user + * happens to own as a regular file is never treated as one of ours. */ +export function getWorktreeSharedLinkPaths(repo: Pick): string[] { + return Array.from( + new Set([...(repo.symlinkPaths ?? []), ...getConfiguredWorktreeSharedDirectories(repo.path)]) + ) +} + +/** Resolve `worktree.sharedDirectories` from the repo-root `orca.yaml` to + * concrete repo-relative directories to symlink into a new worktree. + * + * Only directories that exist in the primary checkout **and** are gitignored are + * returned: tracked directories are already materialized by the checkout, and + * sharing an unignored path would surface the link as a spurious worktree diff. + * + * Never throws — any read/parse/git failure resolves to `[]` so worktree + * creation is never blocked by this file. */ +export async function resolveWorktreeSharedDirectories( + repoPath: string, + options: GitRuntimeOptions = {} +): Promise { + try { + const configured = loadHooks(repoPath)?.worktree?.sharedDirectories ?? [] + if (configured.length === 0) { + return [] + } + + // Keep only entries that exist as directories; a listed but absent path + // (node_modules before install) has nothing to share. + const existing: string[] = [] + for (const relativePath of configured) { + try { + if ((await stat(join(repoPath, relativePath))).isDirectory()) { + existing.push(relativePath) + } else { + console.warn( + `[worktree-shared-directories] Skipping "${relativePath}": sharedDirectories entries must be directories` + ) + } + } catch { + // Absent in the primary checkout — nothing to share. + } + } + if (existing.length === 0) { + return [] + } + + const ignored = new Set(await checkIgnoredPaths(repoPath, existing, options)) + for (const relativePath of existing) { + if (!ignored.has(relativePath)) { + console.warn( + `[worktree-shared-directories] Skipping "${relativePath}": only gitignored directories can be shared` + ) + } + } + return existing.filter((relativePath) => ignored.has(relativePath)).sort() + } catch (error) { + console.warn('[worktree-shared-directories] Failed to resolve shared directories:', error) + return [] + } +} diff --git a/src/main/git/worktree-symlink-detection.ts b/src/main/git/worktree-symlink-detection.ts new file mode 100644 index 00000000000..1b25a7bfe7b --- /dev/null +++ b/src/main/git/worktree-symlink-detection.ts @@ -0,0 +1,45 @@ +import { lstat } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' + +// Why this is a leaf module rather than part of ipc/worktree-symlinks: status +// and review-creation need only the read-only "is this a symlink" question, and +// importing the materialization module would pull APFS cloning — and its +// child_process dependency — into their graph. + +export type SafeRelativePathResult = { safe: true; rel: string } | { safe: false } + +export function getSafeRelativePath(rawPath: string): SafeRelativePathResult { + // Why: strip leading separators (both `/` and `\`) before the guard so + // Windows-style input like `\foo` is normalized the same way POSIX `/foo` + // is, and the traversal check below sees the already-relative form. + const rel = rawPath.trim().replace(/^[\\/]+/, '') + // Why: split on both separators so a Windows-authored `..\escape` is + // rejected the same way POSIX `../escape` is. `path.isAbsolute` catches + // drive-letter absolutes (`C:\...`); the split catches relative + // backslash traversal that `.split('/')` would otherwise miss. + if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { + return { safe: false } + } + return { safe: true, rel } +} + +export async function findExistingWorktreeSymlinkPaths( + worktreePath: string, + paths: readonly string[] +): Promise { + const symlinkPaths: string[] = [] + for (const rawPath of paths) { + const safePath = getSafeRelativePath(rawPath) + if (!safePath.safe) { + continue + } + try { + if ((await lstat(resolve(worktreePath, safePath.rel))).isSymbolicLink()) { + symlinkPaths.push(safePath.rel) + } + } catch { + // Why: only a positively identified symlink may bypass dirty preflight. + } + } + return symlinkPaths +} diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index c208b8e6fe6..1754b70bf09 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1349,23 +1349,32 @@ export async function assertWorktreeCleanForRemoval( timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } ) - if ( - useNullTerminatedStatus - ? hasOnlyIgnoredUntrackedStatus(stdout, ignoredUntrackedPaths) - : !stdout.trim() - ) { + // Why one parse feeds both: the clean verdict and the error text must never + // disagree about which entries block removal. + const blockingEntries = useNullTerminatedStatus + ? getBlockingUntrackedStatusEntries(stdout, ignoredUntrackedPaths) + : null + if (blockingEntries ? blockingEntries.length === 0 : !stdout.trim()) { return } const error = new Error('Worktree has uncommitted or untracked changes.') - ;(error as Error & { stdout?: string }).stdout = stdout + // Why not the raw stdout: `-z` output is NUL-delimited and `.trim()` leaves + // interior NULs, so attaching it verbatim put raw control bytes into the + // user-facing removal error — and listed the tolerated shared link, the one + // entry that is not the user's work and cannot be committed away. + ;(error as Error & { stdout?: string }).stdout = blockingEntries + ? blockingEntries.join('\n') + : stdout throw error } -function hasOnlyIgnoredUntrackedStatus( +/** The `git status --porcelain -z` entries that genuinely block removal: + * everything except the untracked shared links the caller tolerates. */ +function getBlockingUntrackedStatusEntries( status: string, ignoredUntrackedPaths: readonly string[] -): boolean { +): string[] { const ignored = new Set( ignoredUntrackedPaths .map((entry) => @@ -1379,7 +1388,9 @@ function hasOnlyIgnoredUntrackedStatus( return status .split('\0') .filter(Boolean) - .every((entry) => entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/'))) + .filter( + (entry) => !(entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/'))) + ) } function translateWorktreePath( diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index 84788b6c056..9ffa7374225 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -44,7 +44,7 @@ export async function fetchGitHubPullRequestHeadRef( options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { if (!isValidReviewHeadNumber(prNumber)) { - throw new Error(`Invalid pull request number: ${prNumber}`) + throw new Error(`Invalid pull request number: ${String(prNumber)}`) } if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Pull request fetch remote must not start with "-".') diff --git a/src/main/gitlab/mr-head-tracking-ref.ts b/src/main/gitlab/mr-head-tracking-ref.ts index fa3540e1257..e68a8235a9a 100644 --- a/src/main/gitlab/mr-head-tracking-ref.ts +++ b/src/main/gitlab/mr-head-tracking-ref.ts @@ -25,7 +25,7 @@ export async function fetchGitLabMergeRequestHeadRef( options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { if (!isValidReviewHeadNumber(mrIid)) { - throw new Error(`Invalid merge request iid: ${mrIid}`) + throw new Error(`Invalid merge request iid: ${String(mrIid)}`) } if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Merge request fetch remote must not start with "-".') diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 0b37206d6bb..066e539b815 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -10,6 +10,7 @@ import type { GitLabPipelineJob, GitLabWorkItem, GitLabWorkItemDetails, + IssueSourcePreference, MRComment } from '../../shared/types' import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' @@ -24,7 +25,6 @@ import { type LocalGitExecOptions, type ProjectRef } from './gl-utils' -import type { IssueSourcePreference } from '../../shared/types' function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index d117470bffe..6083638575d 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -292,6 +292,82 @@ describe('parseOrcaYaml', () => { ] }) }) + + it('parses worktree.sharedDirectories from orca.yaml', () => { + const result = parseOrcaYaml( + ['worktree:', ' sharedDirectories:', ' - node_modules', ' - .cache'].join('\n') + ) + + expect(result?.worktree?.sharedDirectories).toEqual(['node_modules', '.cache']) + }) + + it('normalizes and dedupes sharedDirectories entries', () => { + const result = parseOrcaYaml( + [ + 'worktree:', + ' sharedDirectories:', + ' - node_modules/', + ' - ./node_modules', + ' - " .cache "' + ].join('\n') + ) + + expect(result?.worktree?.sharedDirectories).toEqual(['node_modules', '.cache']) + }) + + it('drops unsafe sharedDirectories entries', () => { + const result = parseOrcaYaml( + [ + 'worktree:', + ' sharedDirectories:', + ' - ../escape', + ' - /etc', + ' - .git', + ' - .git/hooks', + ' - cache/.git/hooks', + ' - node_modules' + ].join('\n') + ) + + expect(result?.worktree?.sharedDirectories).toEqual(['node_modules']) + }) + + // Why: `resolve()` collapses `.` when the link is created, but Git reports the + // collapsed path — keeping the raw entry would leave a link that every later + // comparison misses, which is the permanently-dirty worktree this feature fixes. + it('drops sharedDirectories entries that still need path collapsing', () => { + const result = parseOrcaYaml( + [ + 'worktree:', + ' sharedDirectories:', + ' - apps/./web/node_modules', + ' - apps//web/.cache', + ' - node_modules' + ].join('\n') + ) + + expect(result?.worktree?.sharedDirectories).toEqual(['node_modules']) + }) + + it('returns null when sharedDirectories is the only key and holds nothing usable', () => { + expect(parseOrcaYaml('worktree:\n sharedDirectories: []\n')).toBeNull() + expect(parseOrcaYaml('worktree:\n sharedDirectories: node_modules\n')).toBeNull() + }) + + it('keeps sharedDirectories alongside other orca.yaml keys', () => { + const result = parseOrcaYaml( + [ + 'scripts:', + ' setup: pnpm install', + 'worktree:', + ' sharedDirectories:', + ' - .cache' + ].join('\n') + ) + + expect(result?.scripts.setup).toBe('pnpm install') + expect(result?.worktree?.sharedDirectories).toEqual(['.cache']) + }) }) describe('hasUnrecognizedOrcaYamlKeys', () => { @@ -335,7 +411,10 @@ describe('hasUnrecognizedOrcaYamlKeys', () => { 'environmentRecipes:', ' - id: cloud-sandbox', ' name: Cloud Sandbox', - ' create: ./scripts/orca-vm/start-cloud-sandbox.sh' + ' create: ./scripts/orca-vm/start-cloud-sandbox.sh', + 'worktree:', + ' sharedDirectories:', + ' - node_modules' ].join('\n') ) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index ef5aaf1e5ea..3f8a210ccf9 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -67,7 +67,8 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([ 'scripts', 'issueCommand', 'defaultTabs', - 'environmentRecipes' + 'environmentRecipes', + 'worktree' ]) /** True when `orca.yaml` has a top-level key this version of Orca does not handle. */ diff --git a/src/main/index.ts b/src/main/index.ts index a76f6f50d94..3ea350d798d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' +import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { Store, @@ -20,7 +21,15 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { killAllPty } from './ipc/pty' +import { + killAllPty, + clearProviderPtyState, + getPtyIdForPaneKey, + registerPaneKeyTeardownListener, + getLocalPtyProvider, + getSshPtyProvider, + registerHeadlessPtyRuntime +} from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' @@ -157,6 +166,7 @@ import { setTrayAttention, type SystemTrayOptions } from './tray/system-tray' +import { createMacAppActivationHandler } from './window/macos-app-activation' import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' @@ -203,14 +213,6 @@ import { setDefaultWslDistroOverride } from './git/runner' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime -} from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' import { browserCertificateTrustController, browserManager } from './browser/browser-manager' @@ -557,6 +559,11 @@ function requestDesktopActivation(): void { desktopActivationGate.requestActivation() } +const handleMacAppActivation = createMacAppActivationHandler({ + getWindow: () => mainWindow, + requestActivation: requestDesktopActivation +}) + function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const state = desktopActivationGate.getState() return state === 'ready' ? 'openable' : state @@ -1194,6 +1201,8 @@ function openMainWindow(): BrowserWindow { prepareLegacySharedCodexSessionResume(args, { isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + getSelectedHostAccountCodexHomePath: () => + codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), onBeforeRelaunch: async () => { @@ -1235,6 +1244,8 @@ function openMainWindow(): BrowserWindow { onWorktreeLifecycle: emitPluginWorktreeLifecycle } ) + // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. + initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) rateLimits.attach(window) // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. rateLimits.start({ fetchImmediately: false }) @@ -1843,7 +1854,7 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { ) } -app.whenReady().then(async () => { +void app.whenReady().then(async () => { logStartupMilestone('app-ready') // Why: install certificate decisions before any webview or headless window issues its first TLS request. app.on( @@ -2172,6 +2183,8 @@ app.whenReady().then(async () => { prepareAiVaultSessionResume: (args) => prepareLegacySharedCodexSessionResume(args, { isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + getSelectedHostAccountCodexHomePath: () => + codexRuntimeHome?.getSelectedHostAccountCodexHomePath() ?? null, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), buildAgentHookPtyEnv: () => @@ -2602,7 +2615,7 @@ app.whenReady().then(async () => { }) startTerminalRuntimeStartupServices() - app.on('activate', requestDesktopActivation) + app.on('activate', handleMacAppActivation) if (serveOptions) { // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. @@ -2726,6 +2739,9 @@ app.whenReady().then(async () => { }) }) +// Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. +process.once('exit', stopTccPromptNotice) + app.on('before-quit', () => { if (isQuittingForUpdate()) { recordUpdaterLifecycle('before_quit_allowed', undefined, { @@ -2748,6 +2764,8 @@ app.on('before-quit', () => { // Why: will-quit fires twice — first pass runs sync cleanup + preventDefault to await checkpoint writes; second pass exits. let daemonDisconnectDone = false app.on('will-quit', (e) => { + // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. + stopTccPromptNotice() const updateQuitInProgress = isQuittingForUpdate() if (updateQuitInProgress) { recordUpdaterLifecycle( diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index ebfd52e63a7..0e44962906e 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -121,21 +121,18 @@ async function scanAiVaultSessionsByHostScope( if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] - return mergeAiVaultListResults( - await Promise.all([ - scanLocalAiVaultSessions(args), - ...getActiveSshAiVaultHostInfos().map((hostInfo) => - scanSshAiVaultSessions(hostInfo.targetId, args) - ), - ...runtimeHosts.hostInfos.map((hostInfo) => - scanRuntimeAiVaultSessions(hostInfo, args, { - timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS - }) - ), - ...runtimeResults - ]), - args?.limit - ) + const scannedResults = await Promise.all([ + scanLocalAiVaultSessions(args), + ...getActiveSshAiVaultHostInfos().map((hostInfo) => + scanSshAiVaultSessions(hostInfo.targetId, args) + ), + ...runtimeHosts.hostInfos.map((hostInfo) => + scanRuntimeAiVaultSessions(hostInfo, args, { + timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS + }) + ) + ]) + return mergeAiVaultListResults([...scannedResults, ...runtimeResults], args?.limit) } const parsed = parseExecutionHostId(executionHostScope) diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index e5cad61d384..22cee5340d2 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' import type { GlobalSettings } from '../../shared/types' @@ -18,6 +19,14 @@ export function registerCodexAccountHandlers( settings }) }) + ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => { + if (!Array.isArray(args?.ptyIds)) { + return {} + } + return listRecordedCodexPaneLanes( + args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string') + ) + }) ipcMain.handle('codexAccounts:forgetStalePanes', (_event, args: { ptyIds?: unknown }) => { if (!Array.isArray(args?.ptyIds)) { return diff --git a/src/main/ipc/diagnostics.ts b/src/main/ipc/diagnostics.ts index 53d8d2f6ef3..5446f2aca4f 100644 --- a/src/main/ipc/diagnostics.ts +++ b/src/main/ipc/diagnostics.ts @@ -20,8 +20,7 @@ import { app, dialog, ipcMain, shell } from 'electron' import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' -import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' -import { tmpdir } from 'node:os' +import { arch as osArch, platform as osPlatform, release as osRelease, tmpdir } from 'node:os' import { join } from 'node:path' import { collectDiagnosticBundle, diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index f75c9c77d39..593607d4a20 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -2,11 +2,13 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' import { getEphemeralVmRecipeResultConnection, - getEphemeralVmRecipeResultWarnings, - redactEphemeralVmRecipeDiagnosticText, - type EphemeralVmRecipeResultWarning, type EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' +import { + getEphemeralVmRecipeResultWarnings, + redactEphemeralVmRecipeDiagnosticText, + type EphemeralVmRecipeResultWarning +} from '../../shared/ephemeral-vm-recipe-diagnostics' // Why: import directly from the doctor module (not the barrel) — it uses Node // fs/path and must stay out of the browser bundle that imports the barrel. import { doctorEphemeralVmRecipe } from '../../shared/ephemeral-vm-recipe-doctor' diff --git a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts new file mode 100644 index 00000000000..8a08c5f07d5 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() + }) +) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } +})) + +import { + closeAllWatchers, + closeRemoteWatcherForWorktreePath, + forgetRemoteWatcherRemovalSnapshot, + registerFilesystemWatcherHandlers, + restoreRemoteWatcherAfterFailedRemoval +} from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> +type TerminalErrorHandler = (error: Error) => void + +const WORKTREE_PATH = '/home/me/repo' +const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } +const OVERFLOW_PAYLOAD = { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] +} + +type MockSender = { + isDestroyed: () => boolean + send: ReturnType + once: ReturnType + id: number + destroy: () => void +} + +function createSender(id: number): MockSender { + let destroyed = false + const destroyedHandlers: (() => void)[] = [] + return { + isDestroyed: () => destroyed, + send: vi.fn(), + once: vi.fn((event: string, handler: () => void) => { + if (event === 'destroyed') { + destroyedHandlers.push(handler) + } + }), + id, + destroy: () => { + destroyed = true + for (const handler of destroyedHandlers) { + handler() + } + } + } +} + +describe('remote filesystem watcher terminal retry resync', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + afterEach(async () => { + await closeAllWatchers() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('resyncs only owners still watching when the terminal retry installs', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const senderOne = createSender(1) + const senderTwo = createSender(2) + let terminalError: TerminalErrorHandler = () => {} + let resolveRetry: (unwatch: () => void) => void = () => {} + const retryInstall = new Promise<() => void>((resolve) => { + resolveRetry = resolve + }) + const watchMock = vi + .fn() + .mockImplementationOnce((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + .mockReturnValueOnce(retryInstall) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender: senderOne }, ARGS) + await handlers['fs:watchWorktree']({ sender: senderTwo }, ARGS) + terminalError(new Error('relay watcher died')) + await vi.advanceTimersByTimeAsync(1_000) + expect(watchMock).toHaveBeenCalledTimes(2) + + handlers['fs:unwatchWorktree']({ sender: senderOne }, ARGS) + resolveRetry(vi.fn()) + await vi.advanceTimersByTimeAsync(0) + + expect(senderOne.send).not.toHaveBeenCalled() + expect(senderTwo.send).toHaveBeenCalledTimes(1) + expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + + terminalError(new Error('late stale failure')) + await vi.advanceTimersByTimeAsync(1_000) + expect(watchMock).toHaveBeenCalledTimes(2) + expect(senderTwo.send).toHaveBeenCalledTimes(1) + }) + + it('does not resync a fast retry for an initial setup failure', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + const watchMock = vi + .fn() + .mockRejectedValueOnce(new Error('provider not ready')) + .mockResolvedValueOnce(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledTimes(2) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('lets provider registration supersede a pending terminal retry', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + terminalError(new Error('old relay watcher died')) + for (const listener of providerRegistrationListeners) { + listener('conn-1') + } + await vi.advanceTimersByTimeAsync(0) + + expect(watchMock).toHaveBeenCalledTimes(2) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + + await vi.advanceTimersByTimeAsync(1_000) + expect(watchMock).toHaveBeenCalledTimes(2) + expect(sender.send).toHaveBeenCalledTimes(1) + }) + + it('aborts an in-flight terminal retry when a replacement provider registers', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + let retrySignal: AbortSignal | undefined + let resolveRetry: (unwatch: () => void) => void = () => {} + const retryInstall = new Promise<() => void>((resolve) => { + resolveRetry = resolve + }) + const retryUnwatch = vi.fn() + const staleWatch = vi + .fn() + .mockImplementationOnce((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + .mockImplementationOnce((_path, _events, options) => { + retrySignal = options.signal + return retryInstall + }) + const replacementWatch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: staleWatch }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + terminalError(new Error('old relay watcher died')) + await vi.advanceTimersByTimeAsync(1_000) + expect(staleWatch).toHaveBeenCalledTimes(2) + + getSshFilesystemProviderMock.mockReturnValue({ watch: replacementWatch }) + for (const listener of providerRegistrationListeners) { + listener('conn-1') + } + expect(retrySignal?.aborted).toBe(true) + + resolveRetry(retryUnwatch) + await vi.advanceTimersByTimeAsync(0) + + expect(retryUnwatch).toHaveBeenCalledTimes(1) + expect(replacementWatch).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + }) + + it('coalesces repeated terminal recovery resyncs with a trailing refresh', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + const terminalErrors: TerminalErrorHandler[] = [] + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalErrors.push(options.onTerminalError) + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + terminalErrors[0]?.(new Error('first watcher failure')) + await vi.advanceTimersByTimeAsync(1_000) + expect(sender.send).toHaveBeenCalledTimes(1) + + terminalErrors[1]?.(new Error('second watcher failure')) + await vi.advanceTimersByTimeAsync(1_000) + terminalErrors[2]?.(new Error('third watcher failure')) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledTimes(4) + expect(sender.send).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(3_000) + expect(sender.send).toHaveBeenCalledTimes(2) + expect(sender.send).toHaveBeenLastCalledWith('fs:changed', OVERFLOW_PAYLOAD) + }) + + it('cancels a terminal retry when the last owner unwatches', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + terminalError(new Error('relay watcher died')) + handlers['fs:unwatchWorktree']({ sender }, ARGS) + await vi.advanceTimersByTimeAsync(60 * 60_000) + + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('cancels a terminal retry when removal forgets the watcher snapshot', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + terminalError(new Error('relay watcher died')) + forgetRemoteWatcherRemovalSnapshot('conn-1', WORKTREE_PATH) + await vi.advanceTimersByTimeAsync(60 * 60_000) + + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('cancels a terminal retry when its renderer is destroyed', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + terminalError(new Error('relay watcher died')) + sender.destroy() + await vi.advanceTimersByTimeAsync(60 * 60_000) + + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('does not retry a terminal callback raised during destructive removal', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + let resolveClose: () => void = () => {} + const closeWatch = vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve + }) + ) + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + const close = closeRemoteWatcherForWorktreePath('conn-1', WORKTREE_PATH) + await Promise.resolve() + terminalError(new Error('close terminated watcher')) + await vi.advanceTimersByTimeAsync(1_000) + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + + resolveClose() + await close + forgetRemoteWatcherRemovalSnapshot('conn-1', WORKTREE_PATH) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) + + it('restores once after removal teardown rejects with a terminal callback', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + let rejectClose: (error: Error) => void = () => {} + const closeWatch = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectClose = reject + }) + ) + const watchMock = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + const close = closeRemoteWatcherForWorktreePath('conn-1', WORKTREE_PATH) + await Promise.resolve() + terminalError(new Error('close terminated watcher')) + rejectClose(new Error('close failed')) + await expect(close).rejects.toThrow('close failed') + await restoreRemoteWatcherAfterFailedRemoval('conn-1', WORKTREE_PATH) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledTimes(2) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + }) + + it('does not carry a shutdown terminal callback into a reopened lifecycle', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createSender(1) + let terminalError: TerminalErrorHandler = () => {} + const initialWatch = vi.fn().mockImplementation((_path, _events, options) => { + terminalError = options.onTerminalError + return Promise.resolve(() => terminalError(new Error('shutdown terminated watcher'))) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: initialWatch }) + + await handlers['fs:watchWorktree']({ sender }, ARGS) + await closeAllWatchers() + + const reopenedWatch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: reopenedWatch }) + await handlers['fs:watchWorktree']({ sender }, ARGS) + await vi.advanceTimersByTimeAsync(1_000) + + expect(initialWatch).toHaveBeenCalledTimes(1) + expect(reopenedWatch).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index a4f13ff82ff..b65a85c9665 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -913,6 +913,13 @@ const pendingRemoteWatcherRetryListeners = new Map< string, { listeners: Map; startedAt: number; resyncOnInstall: boolean } >() +type RemoteWatcherResyncState = { + lastSentAt: number + listeners: Map + timer?: ReturnType + worktreePath: string +} +const remoteWatcherResyncStates = new Map() // Why: last-listener cleanup aborts relay setup; late success is unwatched rather than installed after the renderer stopped watching. const inFlightRemoteInstalls = new Map() // Why: dedupe concurrent installRemoteWatcher calls per key so overlapping watches share one watcher instead of clobbering per-key state. @@ -924,6 +931,8 @@ let remoteWatcherLifecycleGeneration = 0 let unsubscribeFromProviderRegistrations: (() => void) | null = null const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 +// Why: preserve the first and latest resync while bounding full-tree SSH refreshes during flaps. +const REMOTE_WATCH_RESYNC_COALESCE_MS = 5_000 // Why: doubling from a minute to a half-hour ceiling costs a permanently broken remote ~7 fs.watch // calls in the first hour and 2/hour after, which a flapping link can absorb. const REMOTE_WATCH_DORMANT_RETRY_MS = 60_000 @@ -953,6 +962,7 @@ export async function closeRemoteWatcherForWorktreePath( if (suspended.listeners.size > 0) { suspendedRemoteWatcherListeners.set(key, suspended) } + clearRemoteWatcherResync(key) const retryTimer = pendingRemoteWatcherRetries.get(key) if (retryTimer) { clearTimeout(retryTimer) @@ -1006,6 +1016,13 @@ export function forgetRemoteWatcherRemovalSnapshot( ): void { const key = remoteWatcherKey(connectionId, worktreePath) suspendedRemoteWatcherListeners.delete(key) + clearRemoteWatcherResync(key) + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + pendingRemoteWatcherRetryListeners.delete(key) // Why: the worktree is gone — keeping the intent lets a reconnect landing before the renderer's // unwatch re-watch a deleted path (60s of retries against the host, then a bogus overflow). desiredRemoteWatchers.delete(key) @@ -1272,9 +1289,95 @@ function handleRemoteWatcherTerminalError( return } remoteWatchers.delete(key) + if (remoteWatchersClosed || suspendedRemoteWatcherListeners.has(key)) { + return + } console.warn(`[filesystem-watcher] SSH watcher terminated for ${key}:`, error) + const startedAt = Date.now() for (const listener of state.listeners.values()) { - scheduleRemoteWatcherRetry(listener, connectionId, worktreePath) + scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, startedAt, true) + } +} + +function isCurrentDesiredRemoteWatcher(key: string, listener: WebContents): boolean { + return desiredRemoteWatchers.get(key)?.listeners.get(listener.id) === listener +} + +function clearRemoteWatcherResync(key: string): void { + const state = remoteWatcherResyncStates.get(key) + if (state?.timer) { + clearTimeout(state.timer) + } + remoteWatcherResyncStates.delete(key) +} + +function flushRemoteWatcherResync(key: string): void { + const state = remoteWatcherResyncStates.get(key) + if (!state) { + return + } + state.timer = undefined + if ( + remoteWatchersClosed || + suspendedRemoteWatcherListeners.has(key) || + !remoteWatchers.has(key) + ) { + if (!desiredRemoteWatchers.has(key)) { + remoteWatcherResyncStates.delete(key) + } + return + } + let sent = false + for (const listener of state.listeners.values()) { + if (listener.isDestroyed() || !isCurrentDesiredRemoteWatcher(key, listener)) { + continue + } + try { + listener.send('fs:changed', { + worktreePath: state.worktreePath, + events: [{ kind: 'overflow', absolutePath: state.worktreePath }] + } satisfies FsChangedPayload) + sent = true + } catch (error) { + console.warn(`[filesystem-watcher] failed to send SSH watcher resync for ${key}:`, error) + } + } + state.listeners.clear() + if (sent) { + state.lastSentAt = Date.now() + } else { + remoteWatcherResyncStates.delete(key) + } +} + +function requestRemoteWatcherResync( + key: string, + worktreePath: string, + listeners: Iterable +): void { + const state = remoteWatcherResyncStates.get(key) ?? { + lastSentAt: Number.NEGATIVE_INFINITY, + listeners: new Map(), + worktreePath + } + state.worktreePath = worktreePath + for (const listener of listeners) { + if (!listener.isDestroyed() && isCurrentDesiredRemoteWatcher(key, listener)) { + state.listeners.set(listener.id, listener) + } + } + if (state.listeners.size === 0) { + return + } + remoteWatcherResyncStates.set(key, state) + const delayMs = Math.max(0, state.lastSentAt + REMOTE_WATCH_RESYNC_COALESCE_MS - Date.now()) + if (delayMs === 0) { + flushRemoteWatcherResync(key) + return + } + if (!state.timer) { + state.timer = setTimeout(() => flushRemoteWatcherResync(key), delayMs) + state.timer.unref?.() } } @@ -1308,9 +1411,10 @@ function scheduleRemoteWatcherRetry( pendingRemoteWatcherRetries.delete(key) pendingRemoteWatcherRetryListeners.delete(key) loggedUnavailableRemoteWatchers.delete(key) + clearRemoteWatcherResync(key) // Why: handler already resolved so the renderer thinks the watch is live; emit overflow to force a manual refresh instead of waiting forever. for (const listener of retry.listeners.values()) { - if (listener.isDestroyed()) { + if (listener.isDestroyed() || !isCurrentDesiredRemoteWatcher(key, listener)) { continue } console.warn( @@ -1330,21 +1434,18 @@ function scheduleRemoteWatcherRetry( pendingRemoteWatcherRetries.delete(key) pendingRemoteWatcherRetryListeners.delete(key) const listeners = Array.from(retry.listeners.values()).filter( - (listener) => !listener.isDestroyed() + (listener) => !listener.isDestroyed() && isCurrentDesiredRemoteWatcher(key, listener) ) void Promise.all( listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) ) .then((results) => { if (retry.resyncOnInstall) { - for (const [index, listener] of listeners.entries()) { - if (results[index] === 'installed' && !listener.isDestroyed()) { - listener.send('fs:changed', { - worktreePath, - events: [{ kind: 'overflow', absolutePath: worktreePath }] - } satisfies FsChangedPayload) - } - } + requestRemoteWatcherResync( + key, + worktreePath, + listeners.filter((_, index) => results[index] === 'installed') + ) } // Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires. if (results.some((result) => result === 'unavailable')) { @@ -1488,6 +1589,7 @@ function forgetDesiredRemoteWatcher(key: string, senderId: number): void { desired.listeners.delete(senderId) if (desired.listeners.size === 0) { desiredRemoteWatchers.delete(key) + clearRemoteWatcherResync(key) clearDormantRemoteWatcher(key) } } @@ -1563,15 +1665,11 @@ async function rearmDormantRemoteWatcher( scheduleDormantRemoteWatcherRearm(connectionId, worktreePath, nextDormantDelayMs(delayMs)) return } - for (const [index, listener] of listeners.entries()) { - if (results[index] !== 'installed' || listener.isDestroyed()) { - continue - } - listener.send('fs:changed', { - worktreePath, - events: [{ kind: 'overflow', absolutePath: worktreePath }] - } satisfies FsChangedPayload) - } + requestRemoteWatcherResync( + key, + worktreePath, + listeners.filter((_, index) => results[index] === 'installed') + ) // Why: 'cancelled' means shutdown or the last listener left, so only 'unavailable' stays dormant. if (results.some((result) => result === 'unavailable')) { scheduleDormantRemoteWatcherRearm(connectionId, worktreePath, nextDormantDelayMs(delayMs)) @@ -1625,6 +1723,13 @@ function reinstallRemoteWatchersForConnection(connectionId: string): void { pendingRemoteWatcherRetries.delete(key) pendingRemoteWatcherRetryListeners.delete(key) } + // Why: a pending watch belongs to the replaced transport; joiners must retry on the new provider. + const inFlight = inFlightRemoteInstalls.get(key) + if (inFlight) { + inFlight.listeners.clear() + inFlight.cancelled = true + inFlight.abortController.abort() + } // Why: this reinstall supersedes the pending backoff; leaving it armed double-installs the key. clearDormantRemoteWatcher(key) loggedUnavailableRemoteWatchers.delete(key) @@ -1636,17 +1741,12 @@ function reinstallRemoteWatchersForConnection(connectionId: string): void { ) ) .then((results) => { - for (const [index, listener] of listeners.entries()) { - if (results[index] !== 'installed' || listener.isDestroyed()) { - continue - } - // Why: events between the transport dropping and this reinstall are gone for good; - // overflow is the existing "resync, I can't tell you what changed" signal. - listener.send('fs:changed', { - worktreePath: desired.worktreePath, - events: [{ kind: 'overflow', absolutePath: desired.worktreePath }] - } satisfies FsChangedPayload) - } + // Why: events between the transport dropping and this reinstall are gone for good. + requestRemoteWatcherResync( + key, + desired.worktreePath, + listeners.filter((_, index) => results[index] === 'installed') + ) if (results.some((result) => result === 'unavailable')) { for (const listener of listeners) { scheduleRemoteWatcherRetry( @@ -1701,6 +1801,12 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() pendingRemoteWatcherRetryListeners.clear() + for (const state of remoteWatcherResyncStates.values()) { + if (state.timer) { + clearTimeout(state.timer) + } + } + remoteWatcherResyncStates.clear() for (const dormant of dormantRemoteWatchers.values()) { clearTimeout(dormant.timer) } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index b3465ac1617..e712a2938fc 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1376,6 +1376,32 @@ describe('registerFilesystemHandlers', () => { expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: false }) }) + it('passes configured shared links through the local status path', async () => { + const sharedStore = { + ...store, + getRepos: () => [ + { + ...store.getRepos()[0], + symlinkPaths: ['node_modules'] + } + ], + getAllWorktreeMeta: () => ({ + [`repo-1::${WORKTREE_FEATURE_PATH}`]: {} + }) + } + registerWorktreeRootsForRepo(sharedStore as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [] }) + + registerFilesystemHandlers(sharedStore as never) + + await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + sharedLinkPaths: ['node_modules'] + }) + }) + it('allows git operations on the known repo root without rebuilding the worktree cache', async () => { getStatusMock.mockResolvedValue({ entries: [] }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 726d5b0b89d..466166cfcad 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -100,7 +100,11 @@ import { import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' -import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' +import { + getLocalGitOptionsForRegisteredWorktree, + getLocalGitOptionsForRepo, + getLocalRepoForRegisteredWorktree +} from './local-worktree-runtime-options' import { resolveSourceControlAiLinkedIssue } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' @@ -128,6 +132,7 @@ import { registerLocalLogTailHandlers } from './local-log-tail' import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' import { sanitizeLocalDownloadFilename } from '../local-download-filename' import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' +import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' // Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. @@ -1116,12 +1121,16 @@ export function registerFilesystemHandlers( return await provider.getStatus(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return await getStatus(worktreePath, { ...options, ...gitOptions }) + // Why: one registered-worktree lookup feeds both — status polls this + // handler, and the scan walks every repo's worktree meta. + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] + return await getStatus(worktreePath, { + ...options, + ...gitOptions, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }) } finally { gitStatusCancellations.finish(event, args.requestToken, controller) } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index a01225bfccf..6fcc53bab71 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -13,7 +13,8 @@ import type { GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, - PRRefreshOutcome + PRRefreshOutcome, + GitHubPRFile } from '../../shared/types' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { TaskSourceContext } from '../../shared/task-source-context' @@ -68,7 +69,6 @@ import { type PRRefreshValidationDenialReason } from '../github/pr-refresh-validation-backoff' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' import { getProjectViewTable, diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index ab5985524c2..2f0e3e155b2 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -168,6 +168,70 @@ describe('registerHostedReviewHandlers', () => { ) }) + // Why: without this the dirty preflight counts Orca's own shared symlinks as + // user work and Create Review tells the user to commit a link they cannot + // commit (issue #10451). Nothing else asserts the handler supplies them. + it('passes the repo shared link paths through local review creation', async () => { + const localRepo = { + id: 'repo-local', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0, + symlinkPaths: ['node_modules'] + } + store.getRepo.mockImplementation((repoId: string) => + repoId === localRepo.id ? localRepo : null + ) + store.getRepos.mockReturnValue([localRepo]) + const resolvedWorktreePath = resolve('/workspace/feature') + resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) + listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' }) + + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:create'](null, { + repoPath: localRepo.path, + repoId: localRepo.id, + worktreePath: '/workspace/feature', + provider: 'github', + base: 'main', + head: 'feature/pr', + title: 'Feature PR' + }) + + expect(createHostedReviewMock).toHaveBeenCalledWith( + resolvedWorktreePath, + expect.anything(), + null, + { sharedLinkPaths: ['node_modules'] } + ) + }) + + // Why: remote creation never materializes these links, and `repo.path` names a + // path on the remote host — resolving it locally would read a stranger's file. + it('does not resolve shared link paths for an SSH repo', async () => { + store.getRepo.mockImplementation((repoId: string) => + repoId === repo.id ? { ...repo, symlinkPaths: ['node_modules'] } : null + ) + createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' }) + + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:create'](null, { + repoPath, + repoId: repo.id, + worktreePath, + provider: 'github', + base: 'main', + head: 'feature/pr', + title: 'Feature PR' + }) + + expect(createHostedReviewMock).toHaveBeenCalledWith(worktreePath, expect.anything(), 'ssh-1') + }) + it('routes local WSL project review status through main-process runtime options', async () => { setPlatform('win32') const localRepo = { diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 1ca2ea12045..07e36469ef6 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -16,6 +16,7 @@ import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './filesystem-auth' import { listRepoWorktrees } from '../repo-worktrees' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -122,8 +123,20 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + // Why: the dirty preflight must not count Orca's own shared symlinks as user work (issue #10451). + // Remote creation never materializes them, and `repo.path` is a path on the + // remote host — reading it locally would resolve an unrelated `orca.yaml`. + // Not dead code: SSH ignores these, so this only prevents that read and a poisoned cache entry. + const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo) const executionOptions = - Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : undefined + Object.keys(localGitOptions).length > 0 || sharedLinkPaths.length > 0 + ? { + ...(Object.keys(localGitOptions).length > 0 + ? { localGitExecOptions: localGitOptions } + : {}), + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + } + : undefined const input = { provider: args.provider, base: args.base, diff --git a/src/main/ipc/local-worktree-runtime-options.ts b/src/main/ipc/local-worktree-runtime-options.ts index eea3d50f999..13a13e7f536 100644 --- a/src/main/ipc/local-worktree-runtime-options.ts +++ b/src/main/ipc/local-worktree-runtime-options.ts @@ -5,6 +5,7 @@ import { type LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { splitWorktreeId } from '../../shared/worktree-id' +import type { Repo } from '../../shared/types' function comparableLocalPath(value: string): string { const normalized = resolve(value) @@ -34,6 +35,40 @@ function hasRegisteredWorktreeMetaForRepo( return false } +export function getLocalRepoForRegisteredWorktree( + store: Store, + worktreePath: string, + resolvedWorktreePath: string +): Repo | undefined { + if (typeof store.getRepos !== 'function') { + return undefined + } + + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + return store + .getRepos() + .find( + (repo) => + !repo.connectionId && + (candidatePaths.has(comparableLocalPath(repo.path)) || + hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) + ) +} + +/** Git options for a repo already resolved by `getLocalRepoForRegisteredWorktree`, + * so a caller needing both does not walk every repo's worktree meta twice. */ +export function getLocalGitOptionsForRepo( + store: Store, + repo: Repo | undefined +): LocalProjectWorktreeGitOptions { + if (!repo || typeof store.getProjects !== 'function' || typeof store.getSettings !== 'function') { + return {} + } + // Why: file discovery must use the same resolved runtime as project git, + // terminals, and agents even when the worktree path is a Windows path. + return getLocalProjectWorktreeGitOptions(store, repo) +} + export function getLocalGitOptionsForRegisteredWorktree( store: Store, worktreePath: string, @@ -43,19 +78,8 @@ export function getLocalGitOptionsForRegisteredWorktree( return {} } - const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) - for (const repo of store.getRepos()) { - if (repo.connectionId) { - continue - } - if ( - candidatePaths.has(comparableLocalPath(repo.path)) || - hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths) - ) { - // Why: file discovery must use the same resolved runtime as project git, - // terminals, and agents even when the worktree path is a Windows path. - return getLocalProjectWorktreeGitOptions(store, repo) - } - } - return {} + return getLocalGitOptionsForRepo( + store, + getLocalRepoForRegisteredWorktree(store, worktreePath, resolvedWorktreePath) + ) } diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 2c043fed9aa..85b7007d845 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -59,6 +59,42 @@ describe('registerMobileHandlers', () => { }) }) + it('excludes proxy fake-ip addresses so pairing defaults to LAN (#10404)', async () => { + networkInterfacesMock.mockReturnValue({ + utun4: [ + { family: 'IPv4', internal: false, address: '198.18.0.1' }, + { family: 'IPv4', internal: false, address: '198.19.255.254' } + ], + en0: [ + { family: 'IPv4', internal: false, address: '192.168.50.238' }, + { family: 'IPv4', internal: false, address: '198.17.255.254' }, + { family: 'IPv4', internal: false, address: '198.20.0.1' } + ] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#lan', + endpoint: 'ws://192.168.50.238:6768', + deviceId: 'mobile-lan', + connectionMode: 'automatic' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [ + { name: 'en0', address: '192.168.50.238' }, + { name: 'en0', address: '198.17.255.254' }, + { name: 'en0', address: '198.20.0.1' } + ] + }) + + await handlers.get('mobile:getPairingQR')?.(null, {}) + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ address: '192.168.50.238' }) + ) + }) + it('includes IPv6 addresses (ranked after IPv4) and excludes link-local IPv6', () => { networkInterfacesMock.mockReturnValue({ en0: [ diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index fd46171a032..8d70a0fbca1 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -26,6 +26,10 @@ function isUsableIPv6Address(address: string): boolean { return !/^fe[89ab][0-9a-f]:/i.test(address) } +function isProxyFakeIpIPv4Address(address: string): boolean { + return /^198\.(?:18|19)\./.test(address) +} + // Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't // connectable from a mobile device. We enumerate all non-internal IPv4 and // (non-link-local) IPv6 addresses so the user can choose which one to advertise @@ -44,6 +48,10 @@ function getNetworkInterfaces(): NetworkInterface[] { continue } if (addr.family === 'IPv4') { + // 198.18.0.0/15 proxy fake IPs are only routable inside the desktop proxy. + if (isProxyFakeIpIPv4Address(addr.address)) { + continue + } result.push({ name, address: addr.address }) } else if (addr.family === 'IPv6' && isUsableIPv6Address(addr.address)) { result.push({ name, address: addr.address }) diff --git a/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts b/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts index 2764cb647ca..851981d45aa 100644 --- a/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts +++ b/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts @@ -17,6 +17,7 @@ const { getAzureDevOpsAuthStatusMock, getGiteaAuthStatusMock, detectCommandsInInstallDirsMock, + mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -29,6 +30,7 @@ const { getAzureDevOpsAuthStatusMock: vi.fn(), getGiteaAuthStatusMock: vi.fn(), detectCommandsInInstallDirsMock: vi.fn(), + mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -60,6 +62,7 @@ vi.mock('./local-agent-install-dir-detection', () => ({ // Win32 preflight env merge reads persisted registry PATH; stub it out. vi.mock('../pty/windows-environment-path', () => ({ + mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPath: mergePersistedWindowsPathMock })) diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts new file mode 100644 index 00000000000..38e135d4558 --- /dev/null +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -0,0 +1,103 @@ +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ + registryQueryAsyncMock: vi.fn(), + registryQuerySyncMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal>() + const originalExecFile = original.execFile as ( + command: string, + commandArgs: string[], + commandOptions: unknown, + commandCallback: (error: Error | null, stdout: string, stderr: string) => void + ) => unknown + const registryAwareExecFile = ( + file: string, + args: string[], + options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ): unknown => { + if (file.toLowerCase().endsWith('\\reg.exe')) { + return registryQueryAsyncMock(file, args, options, callback) + } + return originalExecFile(file, args, options, callback) + } + const customPromisify = Symbol.for('nodejs.util.promisify.custom') + Object.defineProperty(registryAwareExecFile, customPromisify, { + value: (originalExecFile as unknown as Record)[customPromisify] + }) + return { + ...original, + execFile: registryAwareExecFile, + execFileSync: registryQuerySyncMock + } +}) + +import { + __resetPersistedWindowsPathCacheForTests, + mergePersistedWindowsPathAsync +} from '../pty/windows-environment-path' +import { execLocalPreflightCommand } from './preflight-command-exec' + +describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { + const originalPath = process.env.Path ?? process.env.PATH ?? '' + const fixtureDirs: string[] = [] + + afterEach(() => { + process.env.Path = originalPath + registryQueryAsyncMock.mockReset() + registryQuerySyncMock.mockReset() + __resetPersistedWindowsPathCacheForTests() + for (const directory of fixtureDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('finds a newly installed executable immediately after a forced refresh', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-path-refresh-')) + fixtureDirs.push(directory) + const command = 'orca-path-refresh-fixture.exe' + copyFileSync( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe'), + join(directory, command) + ) + + let persistedUserPath = '' + registryQuerySyncMock.mockImplementation((_file, args: string[]) => { + const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' + return ` Path REG_SZ ${value}\r\n` + }) + registryQueryAsyncMock.mockImplementation( + ( + _file: string, + args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ) => { + const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' + callback(null, ` Path REG_SZ ${value}\r\n`, '') + return {} as never + } + ) + __resetPersistedWindowsPathCacheForTests() + + await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ + code: 'ENOENT' + }) + + persistedUserPath = directory + const refreshOptions = { forceRefresh: true } + await mergePersistedWindowsPathAsync(process.env, refreshOptions) + + await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ + stdout: expect.any(String) + }) + expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) + expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 2966e1fbef6..fead0c97c86 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -15,6 +15,7 @@ const { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -28,6 +29,7 @@ const { getGiteaAuthStatusMock: vi.fn(), resolveCliCommandsMock: vi.fn(), isCommandOnLocalPathMock: vi.fn(), + mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -64,6 +66,7 @@ vi.mock('./command-path-resolver', () => ({ })) vi.mock('../pty/windows-environment-path', () => ({ + mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPath: mergePersistedWindowsPathMock })) @@ -122,6 +125,8 @@ describe('preflight', () => { getBitbucketAuthStatusMock.mockReset() getAzureDevOpsAuthStatusMock.mockReset() getGiteaAuthStatusMock.mockReset() + mergePersistedWindowsPathAsyncMock.mockReset() + mergePersistedWindowsPathAsyncMock.mockResolvedValue(undefined) mergePersistedWindowsPathMock.mockReset() // Why: existing tests should keep treating `which` as the only source // unless a case explicitly exercises the install-dir fallback. @@ -438,6 +443,73 @@ describe('preflight', () => { expect(execFileAsyncMock).toHaveBeenCalledTimes(10) }) + it('awaits the persisted Windows Path refresh before a forced host CLI preflight', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + let finishRefresh!: () => void + mergePersistedWindowsPathAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + finishRefresh = resolve + }) + ) + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) + .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) + + const check = runPreflightCheck(true) + await Promise.resolve() + + expect(execFileAsyncMock).not.toHaveBeenCalled() + finishRefresh() + await expect(check).resolves.toMatchObject({ + gh: { installed: true, authenticated: true } + }) + + expect(mergePersistedWindowsPathAsyncMock).toHaveBeenNthCalledWith(1, process.env, { + forceRefresh: true + }) + }) + + it('does not refresh host Windows Path for forced WSL preflight', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command === 'wsl.exe') { + const script = String(args[5]) + if (script.includes('git') && script.includes('--version')) { + return { stdout: 'git version 2.0.0\n' } + } + if (script.includes('gh') && script.includes('--version')) { + return { stdout: 'gh version 2.0.0\n' } + } + if (script.includes('glab') && script.includes('--version')) { + return { stdout: 'glab version 1.92.1\n' } + } + if (script.includes('gh') && script.includes('auth status')) { + return { stdout: 'github.com\n - Active account: true\n' } + } + if (script.includes('glab') && script.includes('auth status')) { + return { stdout: 'Logged in to gitlab.com\n' } + } + } + throw new Error(`unexpected command ${String(command)}`) + }) + + await expect(runPreflightCheck(true, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({ + gh: { installed: true, authenticated: true } + }) + + expect(mergePersistedWindowsPathAsyncMock).not.toHaveBeenCalled() + }) + it('registers the preflight handler', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 6a33e112fa2..5619495c1cf 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -5,6 +5,7 @@ import { getAzureDevOpsAuthStatus } from '../azure-devops/client' import { getBitbucketAuthStatus } from '../bitbucket/client' import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' +import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' import { getActiveMultiplexer } from './ssh' import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' @@ -228,11 +229,16 @@ export async function runPreflightCheck( force = false, context?: PreflightRuntimeContext ): Promise { - const cacheable = !getPreflightWslTarget(context) + const wslTarget = getPreflightWslTarget(context) + const cacheable = !wslTarget if (cacheable && cached && !force) { return cached } + if (process.platform === 'win32' && !wslTarget) { + await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) + } + if (force) { // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the // first GitLab request and never invalidated within a session. A user who diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2e706452fbb..58b841508bd 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -205,7 +205,10 @@ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, forgetCodexPaneAccount: forgetCodexPaneAccountMock })) -import { LocalPtyProvider } from '../providers/local-pty-provider' +import { + LocalPtyProvider, + _resetLocalPtyProviderStateForTest +} from '../providers/local-pty-provider' import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { @@ -228,7 +231,6 @@ import { restorePtyIncarnation, type PrepareCodexSessionResume } from './pty' -import { _resetLocalPtyProviderStateForTest } from '../providers/local-pty-provider' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' import { _resetHiddenRendererPtyDeliveryGateForTest, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 51cc0d75d12..ef903dcae05 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -34,7 +34,11 @@ import { registerDashboardPopoutHandlers } from './dashboard-popout' import { registerTerminalPreviewHandlers } from './terminal-preview' import { registerDeveloperPermissionHandlers } from './developer-permissions' import { registerComputerUsePermissionHandlers } from './computer-use-permissions' -import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' +import { + setTrustedBrowserRendererWebContentsId, + setAgentBrowserBridgeRef, + registerBrowserHandlers +} from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' import { registerDiagnosticsHandlers } from './diagnostics' @@ -45,7 +49,6 @@ import { registerLocalhostWorktreeLabelHandlers } from './localhost-worktree-lab import { registerAutomationHandlers } from './automations' import { registerKeybindingHandlers } from './keybindings' import { registerTelemetryHandlers } from './telemetry' -import { registerBrowserHandlers } from './browser' import { registerShellHandlers } from './shell' import { registerPetHandlers } from './pet' import { registerPluginHandlers } from './plugins' diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b67afba7e62..0022589443b 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -149,9 +149,16 @@ vi.mock('./ssh', () => ({ import { registerRepoHandlers } from './repos' import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { + getSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' beforeEach(() => { clearGitCapabilityStateForTests() + resetSshProviderAuthorities() }) describe('projectGroups IPC validation', () => { @@ -210,6 +217,128 @@ describe('projectGroups IPC validation', () => { expect(mockStore.createProjectGroup).not.toHaveBeenCalled() }) + it('returns an immutable repo catalog for exactly one execution host', async () => { + const localRepo = { + id: 'duplicate', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + id: 'duplicate', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const runtimeRepo = { + id: 'duplicate', + path: '/runtime/repo', + displayName: 'runtime', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'runtime:environment-a' + } + mockStore.getRepos.mockReturnValue([localRepo, sshRepo, runtimeRepo]) + + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: getSshProviderAuthority('conn-1') + }) + ).resolves.toMatchObject({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:conn-1', + targetId: 'conn-1' + }, + repos: [sshRepo] + }) + + const local = await handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: 'local' + }) + expect(local).toMatchObject({ authoritative: true, repos: [localRepo] }) + expect((local as { repos: object[] }).repos[0]).not.toBe(localRepo) + }) + + it('rejects repo catalogs whose execution host contradicts their SSH connection', async () => { + const baseRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + mockStore.getRepos.mockReturnValue([ + { + ...baseRepo, + connectionId: 'conn-1', + executionHostId: 'local' + } + ]) + + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: 'local' + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: getSshProviderAuthority('conn-1') + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + + mockStore.getRepos.mockReturnValue([ + { + ...baseRepo, + connectionId: 'conn-1', + executionHostId: toSshExecutionHostId('conn-2') + } + ]) + + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: getSshProviderAuthority('conn-1') + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + }) + + it('rejects runtime, partial, mismatched, and stale catalog authority', async () => { + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: 'runtime:environment-a' + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1') + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + await expect( + handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority: { + ...getSshProviderAuthority('other-target'), + targetId: 'other-target' + } + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + + const expectedAuthority = getSshProviderAuthority('conn-1') + const pending = handlers.get('repos:listForExecutionHost')!(null, { + executionHostId: toSshExecutionHostId('conn-1'), + expectedAuthority + }) + rotateSshProviderAuthority('conn-1') + await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) + }) + it('rejects malformed local project group update arguments before persistence', () => { expect(() => handlers.get('projectGroups:update')!(null, { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 185096e5ff6..1a456e23410 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -83,11 +83,16 @@ import { track } from '../telemetry/client' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../../shared/host-repo-catalog-contract' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection' import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, type ExecutionHostId @@ -101,6 +106,8 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { runWithGitReadCacheInvalidation } from '../git/status' +import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' +import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority' // Why: `method` is the IPC entry point the user took, not what they added (never path/URL/name); repos:create → 'folder_picker'. // Why: `isGitRepo` is a non-identifying git-vs-folder signal from the caller's detection; pass undefined when unknown, never default false. @@ -119,6 +126,102 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?: track('repo_added', props) } +function hasValidCatalogSshAuthority( + args: ListReposForExecutionHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +function repoHostContradictsConnection(repo: Repo): boolean { + if (!repo.executionHostId || !repo.connectionId) { + return false + } + const explicitHost = parseExecutionHostId(repo.executionHostId) + return explicitHost?.kind !== 'ssh' || explicitHost.targetId !== repo.connectionId +} + +function getConsistentRepoCatalogForHost( + repos: readonly Repo[], + host: NonNullable> +): Repo[] | null { + const hasContradiction = repos.some( + (repo) => + repoHostContradictsConnection(repo) && + (getRepoExecutionHostId(repo) === host.id || + (host.kind === 'ssh' && repo.connectionId === host.targetId)) + ) + return hasContradiction ? null : repos.filter((repo) => getRepoExecutionHostId(repo) === host.id) +} + +async function listReposForExecutionHost( + store: Store, + args: ListReposForExecutionHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostRepoCatalogSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + const repos = getConsistentRepoCatalogForHost(store.getRepos(), parsedHost) + if (!repos) { + return rejected('rejected') + } + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + repos: structuredClone(repos) + } + } + if ( + !hasValidCatalogSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + const authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + const provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + const matchingRepos = getConsistentRepoCatalogForHost(store.getRepos(), parsedHost) + if (!matchingRepos) { + return rejected('rejected') + } + const repos = structuredClone(matchingRepos) + await Promise.resolve() + if ( + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority) + ) { + return rejected('stale') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + repos + } +} + function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult { const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) const project = store.getProjects().find((entry) => entry.id === setup.projectId) @@ -1119,6 +1222,7 @@ async function runNestedRepoScanForIpc( export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') + ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:remove') ipcMain.removeHandler('repos:removeForHost') @@ -1175,6 +1279,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return store.getRepos() }) + ipcMain.handle( + 'repos:listForExecutionHost', + (_event, args: ListReposForExecutionHostArgs): Promise => + listReposForExecutionHost(store, args) + ) + ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: () => notifyReposChanged(mainWindow) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index a03bd57288c..e2d12ca57ba 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -13,6 +13,7 @@ import type { import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' import { SkillUpdateRunner } from '../skills/skill-update-run' import { skillUpdateFailedNames } from '../skills/skill-update-outcome' +import { readGloballyUpdatableSkillLocks } from '../skills/skill-update-registration' import { discoverSkillsOnTarget, resolveSkillDiscoveryTarget @@ -31,8 +32,13 @@ export function registerSkillsHandlers(store: Store): void { // Why: per-skill outcomes come from re-hashing what is actually on disk, not // from scraping stdout. rescanOutdatedNames: async (names) => { - const inventory = await scanInventory() - return skillUpdateFailedNames(names, inventory.installations) + // The lock read is fresh on purpose: the run just rewrote it, and the + // verdict accepts unrecognized content only when disk matches that record. + const [inventory, globalSkillLocks] = await Promise.all([ + scanInventory(), + readGloballyUpdatableSkillLocks() + ]) + return skillUpdateFailedNames(names, inventory.installations, globalSkillLocks) }, onState: (run: SkillUpdateRun) => { for (const window of BrowserWindow.getAllWindows()) { diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 964c877a557..f42c64a803d 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import type { SshConnectionManager } from '../ssh/ssh-connection' +import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 3780b827ef4..a02e36ca7eb 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -15,6 +15,7 @@ const { mockPtyProvider, mockFsProvider, mockGitProvider, + mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, @@ -68,6 +69,7 @@ const { }, mockFsProvider: {}, mockGitProvider: {}, + mockRegisterSshGitProvider: vi.fn(), mockPortForwardManager: { addForward: vi.fn(), updateForward: vi.fn(), @@ -106,7 +108,7 @@ vi.mock('../ssh/ssh-connection-store', () => ({ } })) -vi.mock('../ssh/ssh-connection', () => ({ +vi.mock('../ssh/ssh-connection-manager', () => ({ SshConnectionManager: class MockSshConnectionManager { constructor(callbacks: unknown) { const manager = (mockNextConnectionManagers.shift() ?? @@ -182,7 +184,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - registerSshGitProvider: vi.fn(), + registerSshGitProvider: mockRegisterSshGitProvider, unregisterSshGitProvider: vi.fn() })) @@ -335,6 +337,7 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() + mockRegisterSshGitProvider.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() @@ -504,6 +507,45 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.connect).toHaveBeenCalledWith(target) }) + it('registers the provider before broadcasting connected authority', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const connectedIndex = mockWindow.webContents.send.mock.calls.findIndex( + ([channel, payload]) => + channel === 'ssh:state-changed' && + (payload as { state: SshConnectionState }).state.status === 'connected' + ) + expect(connectedIndex).toBeGreaterThanOrEqual(0) + expect(mockRegisterSshGitProvider.mock.invocationCallOrder[0]).toBeLessThan( + mockWindow.webContents.send.mock.invocationCallOrder[connectedIndex] + ) + expect(mockWindow.webContents.send.mock.calls[connectedIndex]?.[1]).toEqual({ + targetId: 'ssh-1', + state: expect.objectContaining({ + targetId: 'ssh-1', + status: 'connected', + providerEpoch: expect.any(String), + connectionGeneration: 1 + }) + }) + }) + it('ssh:connect exposes the detected remote platform in public state', async () => { const target: SshTarget = { id: 'ssh-1', @@ -539,6 +581,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 1, remotePlatform: 'win32' }) @@ -549,6 +592,7 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 1, supportsFolderDownload: true, remotePlatform: 'win32' @@ -584,45 +628,46 @@ describe('SSH IPC handlers', () => { onDispose?.('connection_lost') - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'reconnecting', - error: 'Relay channel lost. Reconnecting...', - reconnectAttempt: 1, - connectionGeneration: 1 - } - }) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + const reconnectingState = handlers.get('ssh:getState')!(null, { + targetId: 'ssh-1' + }) as SshConnectionState + expect(reconnectingState).toEqual({ targetId: 'ssh-1', status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', reconnectAttempt: 1, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 + }) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: reconnectingState }) await vi.advanceTimersByTimeAsync(500) - expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { - targetId: 'ssh-1', - state: { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - connectionGeneration: 1, - supportsFolderDownload: true - } - }) - expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + const connectedState = handlers.get('ssh:getState')!(null, { + targetId: 'ssh-1' + }) as SshConnectionState + expect(connectedState).toEqual({ targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: reconnectingState.providerEpoch, + connectionGeneration: reconnectingState.connectionGeneration }) - expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).not.toThrow() + expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:state-changed', { + targetId: 'ssh-1', + state: { + ...connectedState, + supportsFolderDownload: true + } + }) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 1)).toThrow( + 'SSH connection changed; refresh and try again' + ) + expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', 2)).not.toThrow() } finally { vi.useRealTimers() } @@ -677,6 +722,7 @@ describe('SSH IPC handlers', () => { status: 'reconnecting', error: 'Relay channel reconnecting...', reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 2 }) expect(() => assertSshMutationExpectation('ssh-1', 'ssh-1', stagedGeneration)).toThrow( @@ -866,7 +912,8 @@ describe('SSH IPC handlers', () => { status: 'reconnecting', error: 'Relay channel lost. Reconnecting...', reconnectAttempt: 1, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 }) mockDeployAndLaunchRelay.mockClear() @@ -877,7 +924,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 2 + providerEpoch: expect.any(String), + connectionGeneration: 3 }) expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') @@ -887,7 +935,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 2 + providerEpoch: expect.any(String), + connectionGeneration: 3 }) } finally { vi.useRealTimers() @@ -918,7 +967,7 @@ describe('SSH IPC handlers', () => { try { await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - for (const delayMs of relayReconnectDelaysMs) { + for (const [index, delayMs] of relayReconnectDelaysMs.entries()) { useSlowRelayLaunchOnce(relayLostStabilizedMs + 1) getLatestRelayDisposeCallback()('connection_lost') await vi.advanceTimersByTimeAsync(delayMs + relayLostStabilizedMs + 1) @@ -927,7 +976,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: index + 2 }) } @@ -938,7 +988,8 @@ describe('SSH IPC handlers', () => { status: 'error', error: 'Relay channel kept dropping. Click Reconnect on the SSH target before retrying.', reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: relayReconnectDelaysMs.length + 2 }) } finally { vi.useRealTimers() @@ -976,7 +1027,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 }) await vi.advanceTimersByTimeAsync(relayLostStabilizedMs + 1) @@ -988,7 +1040,8 @@ describe('SSH IPC handlers', () => { status: 'connected', error: null, reconnectAttempt: 0, - connectionGeneration: 1 + providerEpoch: expect.any(String), + connectionGeneration: 2 }) expect(mockPortForwardManager.removeAllForwards).not.toHaveBeenCalled() expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() @@ -1195,6 +1248,7 @@ describe('SSH IPC handlers', () => { await expect(handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })).resolves.toEqual({ ...connectedState, + providerEpoch: expect.any(String), connectionGeneration: 1 }) expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() @@ -1396,6 +1450,7 @@ describe('SSH IPC handlers', () => { status: 'error', error: 'network down', reconnectAttempt: 0, + providerEpoch: expect.any(String), connectionGeneration: 1 } }) @@ -1451,6 +1506,30 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('lets a same-turn disconnect invalidate connect before transport admission', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.disconnect.mockResolvedValue(undefined) + + const connect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + const disconnect = handlers.get('ssh:disconnect')!(null, { + targetId: 'ssh-1' + }) as Promise + + await disconnect + await expect(connect).rejects.toThrow('SSH connection attempt was cancelled') + expect(mockConnectionManager.connect).not.toHaveBeenCalled() + }) + it('invalidates a pending connect when disconnect wins and allows a fresh connect', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1462,11 +1541,30 @@ describe('SSH IPC handlers', () => { const staleConn = {} const freshConn = {} let resolveStaleConnect!: (connection: unknown) => void + let resolveForwardRemoval!: () => void + let transportConnectPending = false mockSshStore.getTarget.mockReturnValue(target) - mockConnectionManager.connect.mockReturnValueOnce( - new Promise((resolve) => { - resolveStaleConnect = resolve + mockConnectionManager.connect + .mockReturnValueOnce( + new Promise((resolve) => { + transportConnectPending = true + resolveStaleConnect = resolve + }) + ) + .mockImplementationOnce(async () => { + if (transportConnectPending) { + throw new Error('Connection to Server is already in progress') + } + return freshConn }) + mockConnectionManager.disconnect.mockImplementationOnce(async () => { + transportConnectPending = false + }) + mockPortForwardManager.removeAllForwards.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveForwardRemoval = resolve + }) ) mockConnectionManager.getState.mockReturnValue({ targetId: 'ssh-1', @@ -1480,17 +1578,224 @@ describe('SSH IPC handlers', () => { }) as Promise await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) - await handlers.get('ssh:disconnect')!(null, { targetId: 'ssh-1' }) - mockConnectionManager.connect.mockResolvedValueOnce(freshConn) + const disconnect = handlers.get('ssh:disconnect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) const freshConnect = handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) as Promise + resolveStaleConnect(staleConn) + await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveForwardRemoval() + await disconnect await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) - resolveStaleConnect(staleConn) + await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) + expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) + }) + + it('keeps reconnect behind transport disconnect when forward teardown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveTransportDisconnect!: () => void + let transportDisconnectPending = false + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValueOnce({}).mockImplementationOnce(async () => { + if (transportDisconnectPending) { + throw new Error('Connection to Server is already in progress') + } + return {} + }) + mockConnectionManager.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + transportDisconnectPending = true + resolveTransportDisconnect = () => { + transportDisconnectPending = false + resolve() + } + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( + new Error('forward teardown failed') + ) + + const disconnect = handlers.get('ssh:disconnect')!(null, { + targetId: 'ssh-1' + }) as Promise + const disconnectSettled = vi.fn() + void disconnect.then(disconnectSettled, disconnectSettled) + await vi.waitFor(() => + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + ) + const reconnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + const reconnectResult = reconnect.then( + (state) => ({ ok: true as const, state }), + (error: unknown) => ({ ok: false as const, error }) + ) + await Promise.resolve() + + expect(disconnectSettled).not.toHaveBeenCalled() + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveTransportDisconnect() + + await expect(disconnect).rejects.toThrow('forward teardown failed') + await expect(reconnectResult).resolves.toMatchObject({ + ok: true, + state: { targetId: 'ssh-1', status: 'connected' } + }) + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2) + expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') + }) + + it('retires a removed target session after forward teardown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveTransportDisconnect!: () => void + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTransportDisconnect = resolve + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( + new Error('forward teardown failed') + ) + + const removal = handlers.get('ssh:removeTarget')!(null, { + id: 'ssh-1' + }) as Promise + await vi.waitFor(() => + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + ) + await Promise.resolve() + + expect(mockSshStore.removeTarget).not.toHaveBeenCalled() + resolveTransportDisconnect() + await removal + + expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') + expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') + expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') + }) + + it('replaces a stale shared connect after authority rotates without disconnect', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveStaleConnect!: (connection: unknown) => void + let resolveForwardRemoval!: () => void + let resolveTransportDisconnect!: () => void + let transportConnectPending = false + mockSshStore.getTarget.mockReturnValue(target) + mockSshStore.addTarget.mockReturnValue(target) + mockConnectionManager.connect + .mockReturnValueOnce( + new Promise((resolve) => { + transportConnectPending = true + resolveStaleConnect = resolve + }) + ) + .mockImplementationOnce(async () => { + if (transportConnectPending) { + throw new Error('Connection to Server is already in progress') + } + return {} + }) + mockConnectionManager.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTransportDisconnect = () => { + transportConnectPending = false + resolve() + } + }) + ) + mockPortForwardManager.removeAllForwards.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveForwardRemoval = resolve + }) + ) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + const staleConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + const sharedStaleConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1)) + + mockSshStore.lastRepoReadoptions = [ + { oldTargetId: 'ssh-1', newTargetId: 'ssh-new', repoIds: ['repo-1'] } + ] + await handlers.get('ssh:addTarget')!(null, { target }) + const freshConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') + ) + await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) + const sharedFreshConnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveForwardRemoval() + resolveTransportDisconnect() + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) + + resolveStaleConnect({}) await expect(staleConnect).rejects.toThrow('SSH connection attempt was cancelled') + await expect(sharedStaleConnect).rejects.toThrow('SSH connection attempt was cancelled') await expect(freshConnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) + await expect(sharedFreshConnect).resolves.toMatchObject({ + targetId: 'ssh-1', + status: 'connected' + }) expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) }) @@ -1568,6 +1873,61 @@ describe('SSH IPC handlers', () => { expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-lease', 'terminated') }) + it('keeps reconnect behind the complete terminate-sessions lifecycle', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveShutdown!: () => void + let resolveForwardRemoval!: () => void + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' } + ]) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockPtyProvider.shutdown.mockReturnValueOnce( + new Promise((resolve) => { + resolveShutdown = resolve + }) + ) + mockPortForwardManager.removeAllForwards.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveForwardRemoval = resolve + }) + ) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + const terminate = handlers.get('ssh:terminateSessions')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockPtyProvider.shutdown).toHaveBeenCalledOnce()) + const reconnect = handlers.get('ssh:connect')!(null, { + targetId: 'ssh-1' + }) as Promise + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + + resolveShutdown() + await vi.waitFor(() => expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')) + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + resolveForwardRemoval() + await terminate + await vi.waitFor(() => expect(mockConnectionManager.connect).toHaveBeenCalledTimes(2)) + + await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) + }) + it('ssh:terminateSessions ignores expired leases when disconnected', async () => { mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } @@ -1641,6 +2001,43 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('retires the captured session when reset forward teardown fails', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockPortForwardManager.removeAllForwards.mockRejectedValueOnce( + new Error('forward teardown failed') + ) + + await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( + 'forward teardown failed' + ) + expect(mockMux.dispose).toHaveBeenCalledWith('connection_lost') + expect(mockForceStopRelayForTarget).not.toHaveBeenCalled() + + await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) + + expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledTimes(1) + expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + it('ssh:resetRelay waits for an in-flight connect before tearing down the session', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1776,6 +2173,42 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + it('keeps removal behind an in-flight relay reset', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + let resolveForceStop!: () => void + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.getConnection.mockReturnValue({}) + mockConnectionManager.disconnect.mockResolvedValue(undefined) + mockForceStopRelayForTarget.mockReturnValueOnce( + new Promise((resolve) => { + resolveForceStop = resolve + }) + ) + + const reset = handlers.get('ssh:resetRelay')!(null, { + targetId: 'ssh-1' + }) as Promise + await vi.waitFor(() => expect(mockForceStopRelayForTarget).toHaveBeenCalledOnce()) + const removal = handlers.get('ssh:removeTarget')!(null, { + id: 'ssh-1' + }) as Promise + await Promise.resolve() + + expect(mockSshStore.removeTarget).not.toHaveBeenCalled() + resolveForceStop() + await reset + await removal + + expect(mockConnectionManager.disconnect).toHaveBeenCalledTimes(2) + expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') + }) + it('reconnects on system resume when the relay liveness probe fails', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1974,6 +2407,10 @@ describe('SSH IPC handlers', () => { mockConnectionManager.getState.mockReturnValue(state) const result = await handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) - expect(result).toEqual({ ...state, connectionGeneration: 0 }) + expect(result).toEqual({ + ...state, + providerEpoch: expect.any(String), + connectionGeneration: 0 + }) }) }) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 287507985e4..7e1a54026df 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -3,7 +3,8 @@ import { ipcMain, powerMonitor, type BrowserWindow } from 'electron' import { appendFileSync } from 'node:fs' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' -import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' +import type { SshConnectionCallbacks } from '../ssh/ssh-connection' +import { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' @@ -14,7 +15,8 @@ import type { SshRepoReadoption, SshTarget, SshConnectionStatus, - SshConnectionState + SshConnectionState, + DirectSshAuthority } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' @@ -39,11 +41,15 @@ import { } from './pty' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { - advanceSshConnectionGeneration, - getSshConnectionGeneration, initializeSshConnectionGenerationSession, resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -107,34 +113,35 @@ export function listRegisteredRemovedSshTargetLabels(): Record { export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) - if (!connectionManager) { - return - } - await detachActiveSshSession(targetId) - await connectionManager.disconnect(targetId) + await runTargetLifecycle(targetId, () => + teardownSshTargetTransport(targetId, (session) => session.detach()) + ) } export async function removeRegisteredSshTarget(targetId: string): Promise { if (!sshStore) { return } + const store = sshStore invalidateConnectAttempt(targetId) - // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. - await disposeActiveSshSession(targetId) - try { - await connectionManager?.disconnect(targetId) - } catch (err) { - // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. - console.warn( - `[ssh] Failed to disconnect removed target ${targetId}: ${err instanceof Error ? err.message : String(err)}` - ) - } - persistedStore?.removeSshRemotePtyLeases(targetId) - sshStore.removeTarget(targetId) + await runTargetLifecycle(targetId, async () => { + try { + // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. + await teardownSshTargetTransport(targetId, (session) => session.dispose()) + } catch (err) { + // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. + console.warn( + `[ssh] Failed to disconnect removed target ${targetId}: ${err instanceof Error ? err.message : String(err)}` + ) + } + persistedStore?.removeSshRemotePtyLeases(targetId) + store.removeTarget(targetId) + }) } // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). const activeSessions = new Map() +const targetLifecycleInFlight = new Map>() export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { if (isRuntimeOwnedSshTargetId(targetId)) { @@ -153,12 +160,61 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { }) } -async function detachActiveSshSession(targetId: string): Promise { - await teardownActiveSshSession(targetId, (session) => session.detach()) +function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { + const prior = targetLifecycleInFlight.get(targetId) + const operationPromise = (async () => { + if (prior) { + await prior.catch(() => undefined) + } + await operation() + })() + let trackedPromise!: Promise + trackedPromise = operationPromise.finally(() => { + if (targetLifecycleInFlight.get(targetId) === trackedPromise) { + targetLifecycleInFlight.delete(targetId) + } + }) + targetLifecycleInFlight.set(targetId, trackedPromise) + return trackedPromise } -async function disposeActiveSshSession(targetId: string): Promise { - await teardownActiveSshSession(targetId, (session) => session.dispose()) +async function awaitTargetLifecycle(targetId: string): Promise { + while (true) { + const lifecycle = targetLifecycleInFlight.get(targetId) + if (!lifecycle) { + return + } + await lifecycle.catch(() => undefined) + } +} + +async function teardownSshTargetTransport( + targetId: string, + teardown: (session: SshRelaySession) => void +): Promise { + let transportDisconnect: Promise<{ ok: true } | { ok: false; error: unknown }> + try { + transportDisconnect = Promise.resolve(connectionManager?.disconnect(targetId)).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + } catch (error) { + transportDisconnect = Promise.resolve({ ok: false, error }) + } + const sessionTeardown = teardownActiveSshSession(targetId, teardown).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + const [disconnectResult, teardownResult] = await Promise.all([ + transportDisconnect, + sessionTeardown + ]) + if (!teardownResult.ok) { + throw teardownResult.error + } + if (!disconnectResult.ok) { + throw disconnectResult.error + } } async function teardownActiveSshSession( @@ -169,12 +225,26 @@ async function teardownActiveSshSession( if (!session) { return } - // Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE. - await portForwardManager?.removeAllForwards(targetId) - teardown(session) - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) + let teardownError: { error: unknown } | null = null + try { + // Why: await port teardown so local listeners are released before disconnect/remove completes, else an immediate reconnect hits EADDRINUSE. + await portForwardManager?.removeAllForwards(targetId) + } catch (error) { + teardownError = { error } + } + try { + teardown(session) + } catch (error) { + teardownError ??= { error } + } + if (activeSessions.get(targetId) === session) { + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + clearRelayStateOverride(targetId) + } + if (teardownError) { + throw teardownError.error + } } function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined { @@ -184,25 +254,22 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number // Why: tabs must share one connect, while a disconnect must invalidate that // attempt so its late continuation cannot clobber a replacement. type ConnectAttempt = { - generation: number + authority: DirectSshAuthority promise: Promise } const connectInFlight = new Map() const pendingTransportReconnects = new Set() -function currentConnectGeneration(targetId: string): number { - return getSshConnectionGeneration(targetId) -} function invalidateConnectAttempt(targetId: string): void { - advanceSshConnectionGeneration(targetId) + rotateSshProviderAuthority(targetId) pendingTransportReconnects.delete(targetId) connectInFlight.delete(targetId) credentialRequestedForTarget.delete(targetId) } -function isCurrentConnectAttempt(targetId: string, generation: number): boolean { - return currentConnectGeneration(targetId) === generation +function isCurrentConnectAttempt(targetId: string, authority: DirectSshAuthority): boolean { + return authority.targetId === targetId && isCurrentSshProviderAuthority(authority) } function connectCancelledError(): Error { @@ -261,9 +328,12 @@ function broadcastSshState( function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os + const authority = getSshProviderAuthority(targetId) return { ...state, - connectionGeneration: currentConnectGeneration(targetId), + targetId, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration, ...(remotePlatform ? { remotePlatform } : {}) } } @@ -516,10 +586,12 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { // Why: an SSH reconnect must re-deploy the relay and rebuild providers; the guard below fires only for real reconnects, not an explicit connect's 'deploying'. const session = activeSessions.get(targetId) const sessionState = session?.getState() - if ( + const transportReconnectStarted = state.status === 'reconnecting' && - (sessionState === 'ready' || sessionState === 'reconnecting') - ) { + (sessionState === 'ready' || sessionState === 'reconnecting') && + !pendingTransportReconnects.has(targetId) + if (transportReconnectStarted) { + rotateSshProviderAuthority(targetId) pendingTransportReconnects.add(targetId) } else if ( state.status === 'disconnected' || @@ -531,10 +603,6 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { } const completedTransportReconnect = state.status === 'connected' && pendingTransportReconnects.delete(targetId) - if (completedTransportReconnect) { - // Why: staged mutations from the replaced SSH transport must fail even if its relay session disappeared before recovery completed. - advanceSshConnectionGeneration(targetId) - } const shouldReconnectRelay = session !== undefined && completedTransportReconnect && @@ -612,6 +680,9 @@ function broadcastDetectedPortsFromCurrentWindow( function configureRelaySessionCallbacks(session: SshRelaySession): void { session.setOnTerminalRelayError((tid, err) => { clearRelayLostBackoff(tid) + if (activeSessions.get(tid)?.getState() !== 'deploying') { + rotateSshProviderAuthority(tid) + } console.warn( `[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.` ) @@ -642,6 +713,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { if (state.reconnectTimer) { return } + rotateSshProviderAuthority(tid) if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { console.warn( `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` @@ -780,6 +852,11 @@ export function registerSshHandlers( } const repoReadoptions = sshStore.lastRepoReadoptions sshStore.lastRepoReadoptions = [] + for (const targetId of new Set( + repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) + )) { + rotateSshProviderAuthority(targetId) + } const win = getCurrentMainWindow() if (win && !win.isDestroyed()) { win.webContents.send('repos:changed') @@ -827,7 +904,9 @@ export function registerSshHandlers( appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`) throw new Error('e2e_forbidden_local_ssh_connect') } - const observedGeneration = currentConnectGeneration(targetId) + // Why: fence callers that entered before a same-turn disconnect/reset but resume after its cleanup. + const admissionAuthority = getSshProviderAuthority(targetId) + await awaitTargetLifecycle(targetId) const reset = resetRelayInFlight.get(targetId) if (reset) { await reset @@ -835,16 +914,29 @@ export function registerSshHandlers( // Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session. const existing = connectInFlight.get(targetId) + let replacePendingTransport = false if (existing) { - return existing.promise + if (isCurrentConnectAttempt(targetId, existing.authority)) { + return existing.promise + } } - if (currentConnectGeneration(targetId) !== observedGeneration) { + if (!isCurrentConnectAttempt(targetId, admissionAuthority)) { + throw connectCancelledError() + } + const observedAuthority = admissionAuthority + if (existing) { + if (connectInFlight.get(targetId) === existing) { + connectInFlight.delete(targetId) + replacePendingTransport = true + } + } + if (!isCurrentSshProviderAuthority(observedAuthority)) { throw connectCancelledError() } pendingTransportReconnects.delete(targetId) - const promise = doConnect(targetId) - const attempt = { generation: currentConnectGeneration(targetId), promise } + const promise = doConnect(targetId, replacePendingTransport) + const attempt = { authority: getSshProviderAuthority(targetId), promise } connectInFlight.set(targetId, attempt) try { return await promise @@ -862,7 +954,10 @@ export function registerSshHandlers( return connectTarget(args.targetId) }) - async function doConnect(targetId: string): Promise { + async function doConnect( + targetId: string, + replacePendingTransport = false + ): Promise { const target = sshStore!.getTarget(targetId) if (!target) { throw new Error(`SSH target "${targetId}" not found`) @@ -885,20 +980,38 @@ export function registerSshHandlers( return getPublicSshState(targetId)! } - const generation = advanceSshConnectionGeneration(targetId) + const authority = rotateSshProviderAuthority(targetId) clearRelayStateOverride(targetId) + const pendingTransportDisconnect = replacePendingTransport + ? connectionManager!.disconnect(targetId).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + : null let conn // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). if (existingSession) { // Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports. await portForwardManager!.removeAllForwards(targetId) - if (!isCurrentConnectAttempt(targetId, generation)) { + if (!isCurrentConnectAttempt(targetId, authority)) { throw connectCancelledError() } existingSession.detach() - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) + if (activeSessions.get(targetId) === existingSession) { + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + clearRelayStateOverride(targetId) + } + } + + if (pendingTransportDisconnect) { + const disconnectResult = await pendingTransportDisconnect + if (!disconnectResult.ok) { + throw disconnectResult.error + } + if (!isCurrentConnectAttempt(targetId, authority)) { + throw connectCancelledError() + } } // Why: create the session early so onStateChange sees it in 'deploying' and skips reconnect logic. @@ -913,7 +1026,7 @@ export function registerSshHandlers( configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) const ownsSession = (): boolean => - isCurrentConnectAttempt(targetId, generation) && activeSessions.get(targetId) === session + isCurrentConnectAttempt(targetId, authority) && activeSessions.get(targetId) === session try { conn = await connectionManager!.connect(target) @@ -987,66 +1100,60 @@ export function registerSshHandlers( ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { invalidateConnectAttempt(args.targetId) - const session = activeSessions.get(args.targetId) - const provider = getSshPtyProvider(args.targetId) - const leasedIds = persistedStore! - .getSshRemotePtyLeases(args.targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map((lease) => lease.ptyId) - const ptyIdsByRelayId = new Map() - for (const ptyId of getPtyIdsForConnection(args.targetId)) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) - } - for (const ptyId of leasedIds) { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - ptyIdsByRelayId.set( - relayPtyId, - ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId) - ) - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) - - if (ptyIds.length > 0 && !provider) { - throw new Error( - `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` - ) - } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) - : [] - const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push( - `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` - ) - continue + await runTargetLifecycle(args.targetId, async () => { + const provider = getSshPtyProvider(args.targetId) + const leasedIds = persistedStore! + .getSshRemotePtyLeases(args.targetId) + .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') + .map((lease) => lease.ptyId) + const ptyIdsByRelayId = new Map() + for (const ptyId of getPtyIdsForConnection(args.targetId)) { + const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) + ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') - } - if (shutdownFailures.length > 0) { - // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. - throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) - } - if (session) { - await portForwardManager!.removeAllForwards(args.targetId) - session.dispose() - activeSessions.delete(args.targetId) - clearRelayLostBackoff(args.targetId) - clearRelayStateOverride(args.targetId) - } - await connectionManager!.disconnect(args.targetId) + for (const ptyId of leasedIds) { + const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) + ptyIdsByRelayId.set( + relayPtyId, + ptyIdsByRelayId.get(relayPtyId) ?? toAppSshPtyId(args.targetId, ptyId) + ) + } + const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ + relayPtyId, + appPtyId + })) + + if (ptyIds.length > 0 && !provider) { + throw new Error( + `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` + ) + } + const shutdownResults = provider + ? await Promise.allSettled( + ptyIds.map(({ appPtyId }) => + provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) + ) + ) + : [] + const shutdownFailures: string[] = [] + for (const [index, result] of shutdownResults.entries()) { + const { appPtyId, relayPtyId } = ptyIds[index] + if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { + shutdownFailures.push( + `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` + ) + continue + } + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) + persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + } + if (shutdownFailures.length > 0) { + // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. + throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) + } + await teardownSshTargetTransport(args.targetId, (session) => session.dispose()) + }) }) async function doResetRelay(targetId: string, target: SshTarget): Promise { @@ -1060,13 +1167,11 @@ export function registerSshHandlers( } } + rotateSshProviderAuthority(targetId) const session = activeSessions.get(targetId) if (session) { - await portForwardManager!.removeAllForwards(targetId) // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - session.detach() - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) + await teardownActiveSshSession(targetId, (capturedSession) => capturedSession.detach()) } const existingConn = connectionManager!.getConnection(targetId) @@ -1105,13 +1210,13 @@ export function registerSshHandlers( } let resetPromise: Promise - resetPromise = Promise.resolve() - .then(() => doResetRelay(args.targetId, target)) - .finally(() => { - if (resetRelayInFlight.get(args.targetId) === resetPromise) { - resetRelayInFlight.delete(args.targetId) - } - }) + resetPromise = runTargetLifecycle(args.targetId, () => + doResetRelay(args.targetId, target) + ).finally(() => { + if (resetRelayInFlight.get(args.targetId) === resetPromise) { + resetRelayInFlight.delete(args.targetId) + } + }) resetRelayInFlight.set(args.targetId, resetPromise) return resetPromise }) @@ -1299,8 +1404,10 @@ export async function resetSshHandlerStateForTests(): Promise { } relayStateOverrides.clear() connectInFlight.clear() + targetLifecycleInFlight.clear() pendingTransportReconnects.clear() resetSshConnectionGenerations() + resetSshProviderAuthorities() resetRelayInFlight.clear() testingTargets.clear() credentialRequestedForTarget.clear() diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 78f47fbbd41..02f1b9e16be 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -17,8 +17,7 @@ import { getOnboardingCohortAtEmit } from '../telemetry/onboarding-cohort-classi import { resolveConsent, type ConsentState } from '../telemetry/consent' import type { Store } from '../persistence' import { isCohortExtendedEvent, isOnboardingEvent } from '../../shared/telemetry-events' -import type { EventName, EventProps } from '../../shared/telemetry-events' -import type { OptInVia } from '../../shared/telemetry-events' +import type { EventName, EventProps, OptInVia } from '../../shared/telemetry-events' // Module-level store ref: handlers need a synchronous `settings.telemetry` read to derive `via` before any mutation. let storeRef: Store | null = null diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index 90e6058f598..04945327f98 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -7,6 +7,7 @@ import { WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' +import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' export type WorkspaceCleanupGitEvidence = { clean: boolean | null @@ -34,13 +35,17 @@ export async function readWorkspaceCleanupGitEvidence( const blockers: WorkspaceCleanupBlocker[] = [] let status: GitStatusResult const checkedAt = Date.now() + const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo) try { status = await withWorkspaceCleanupTimeout( (signal) => repo.connectionId ? provider!.getStatus(worktree.path, { signal }) - : getStatus(worktree.path, { signal }), + : getStatus(worktree.path, { + signal, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out reading git status.' ) diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 731785ad356..5d5538ca3df 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -82,7 +82,8 @@ const REPO: Repo = { path: '/repo', displayName: 'Repo', badgeColor: '#000', - addedAt: NOW + addedAt: NOW, + symlinkPaths: ['node_modules'] } const LARGE_WORKTREE_COUNT = 150_000 @@ -194,7 +195,10 @@ describe('workspace cleanup scan', () => { it('default-selects inactive workspaces when git status is clean', async () => { const result = await scanWorkspaceCleanup(makeStore()) - expect(getStatusMock).toHaveBeenCalledTimes(1) + expect(getStatusMock).toHaveBeenCalledWith('/repo-feature', { + signal: expect.any(AbortSignal), + sharedLinkPaths: ['node_modules'] + }) expect(result.candidates).toHaveLength(1) expect(result.candidates[0]).toMatchObject({ tier: 'ready', diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index d7ce72a06fd..5ae35f651f6 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -74,6 +74,25 @@ describe('sanitizeWorktreeName', () => { expect(sanitizeWorktreeName('feat: 中文 (v2)')).toBe('feat-中文-v2') }) + it('uses readable git-safe shortcodes for known emoji', () => { + expect(sanitizeWorktreeName('🚀')).toBe('rocket') + expect(sanitizeWorktreeName('👩‍💻✨')).toBe('woman-technologist-sparkles') + expect(sanitizeWorktreeName('🇯🇵')).toBe('jp') + expect(sanitizeWorktreeName('1️⃣')).toBe('one') + }) + + it('keeps readable text and emoji shortcodes in branch and path names', () => { + expect(sanitizeWorktreeName('Ship it 🚀')).toBe('Ship-it-rocket') + }) + + it('uses a git-safe fallback for emoji newer than the shortcode catalog', () => { + expect(sanitizeWorktreeName('\u{1fae9}')).toBe('workspace') + }) + + it('does not treat arbitrary punctuation as a workspace name', () => { + expect(() => sanitizeWorktreeName('!!!')).toThrow('Invalid worktree name') + }) + it('throws for empty name', () => { expect(() => sanitizeWorktreeName('')).toThrow('Invalid worktree name') }) @@ -84,6 +103,11 @@ describe('sanitizeWorktreeName', () => { }) describe('sanitizeWorktreeDisplayName', () => { + it('preserves emoji in display names', () => { + expect(sanitizeWorktreeDisplayName(' Ship it 🚀 ')).toBe('Ship it 🚀') + expect(sanitizeWorktreeDisplayName('👩‍💻')).toBe('👩‍💻') + }) + it('keeps readable punctuation while collapsing unsafe controls and whitespace', () => { expect(sanitizeWorktreeDisplayName(' Fix: login / callback\n\tregression\u0000 ')).toBe( 'Fix: login / callback regression' diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index d040dec5d0f..a056d657f02 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -3,6 +3,7 @@ import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/typ import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' +import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' import { getWslHome, parseWslPath } from '../wsl' type WorktreePathSettings = Pick @@ -25,7 +26,7 @@ export function sanitizeWorktreeName(input: string): string { // name workspaces in their own language. Git ref-format permits non-ASCII // bytes, and modern filesystems handle UTF-8 paths. Only strip characters // git or the filesystem actually rejects. - const sanitized = input + const sanitized = replaceKnownEmojiWithShortcodes(input) .trim() .replace(/[^\p{L}\p{N}._-]+/gu, '-') .replace(/-+/g, '-') @@ -37,6 +38,10 @@ export function sanitizeWorktreeName(input: string): string { .replace(/\.{2,}/g, '.') .replace(/^[.-]+|[.-]+$/g, '') + if (!sanitized && containsEmoji(input)) { + return 'workspace' + } + if (!sanitized || sanitized === '.' || sanitized === '..') { throw new Error('Invalid worktree name') } @@ -44,6 +49,12 @@ export function sanitizeWorktreeName(input: string): string { return sanitized } +function containsEmoji(input: string): boolean { + return /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u.test( + input + ) +} + export function sanitizeWorktreeDisplayName(input: string): string | undefined { const withoutControls = Array.from(input, (char) => { const code = char.charCodeAt(0) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b86e5c0ec42..8977005617e 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -28,7 +28,7 @@ import { resolveDefaultBaseRefViaExec, resolveDefaultBaseRefWithLocalGit } from '../git/repo' -import { resolveLocalGitUsername } from '../git/git-username' +import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' @@ -38,8 +38,11 @@ import { validateGitPushTarget } from '../git/push-target-validation' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from '../git/runner' import { parseGitHubOwnerRepo } from '../github/gh-utils' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' +import type { + OrcaRuntimeService, + RemoteFetchResult, + RemoteTrackingBase +} from '../runtime/orca-runtime' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { buildPosixRunnerScript, @@ -58,7 +61,6 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { getSshGitUsername } from '../git/git-username' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' import { registerOptionalSshWorktreeCreateRoots, @@ -97,9 +99,14 @@ import { prepareWorktreePushTargetWithExec } from './worktree-push-target-setup' import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' -import { createWorktreeCopiedPaths, createWorktreeLinkedPaths } from './worktree-symlinks' +import { + createWorktreeCopiedPaths, + createWorktreeLinkedPaths, + createWorktreeSharedPaths +} from './worktree-symlinks' import { formatWorktreeIncludeCopyWarning } from './worktree-include-copy-budget' import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' +import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' @@ -1870,7 +1877,7 @@ export async function createRemoteWorktree( }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) - // Why: shared/symlink paths and `.worktreeinclude` copies are local-only; remote (SSH) support needs a new relay method + auth surface, so both are skipped here. + // Why: shared/symlink paths, `orca.yaml` shared directories, and `.worktreeinclude` copies are local-only; remote (SSH) support needs a new relay method + auth surface, so all are skipped here. let setup: CreateWorktreeResult['setup'] let defaultTabs: CreateWorktreeResult['defaultTabs'] @@ -2466,6 +2473,17 @@ export async function createLocalWorktree( }) } + // Why: project-level `orca.yaml` shared directories add to (never replace) the per-user + // setting, so a repo's shared dirs reach every teammate (issue #10451). + const sharedDirectories = await timing.time('resolve_shared_directories', () => + resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions) + ) + if (sharedDirectories.length > 0) { + await timing.time('create_shared_directories', async () => { + await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + }) + } + // Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics // (never symlink) so each worktree owns its files. Paths already linked above are skipped. const includePaths = await timing.time('resolve_worktreeinclude', () => diff --git a/src/main/ipc/worktree-symlinks.test.ts b/src/main/ipc/worktree-symlinks.test.ts index 865c3f17b11..ff518b710b2 100644 --- a/src/main/ipc/worktree-symlinks.test.ts +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -17,7 +17,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createWorktreeCopiedPaths, createWorktreeLinkedPaths, + createWorktreeSharedPaths, createWorktreeSymlinks, + worktreeSymlinkTypeCandidates, findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths, removeWorktreeSymlinks @@ -394,6 +396,100 @@ describe('createWorktreeSymlinks', () => { }) }) +// Why: a plain `fs.symlink` needs Developer Mode or admin on Windows, so an +// ordinary Windows user gets EPERM and silently ends up with no shared +// directory at all. A junction needs no privilege — but it cannot target a UNC +// path, which is exactly where a WSL project's repo lives, so the symlink has +// to stay as a fallback rather than be replaced. +describe('worktreeSymlinkTypeCandidates', () => { + it('tries a junction before a symlink for a directory on Windows', () => { + expect(worktreeSymlinkTypeCandidates('win32', true)).toEqual(['junction', 'dir']) + }) + + it('keeps the symlink fallback so a UNC (WSL) target still works', () => { + expect(worktreeSymlinkTypeCandidates('win32', true).at(-1)).toBe('dir') + }) + + it('never uses a junction for a file, which junctions cannot represent', () => { + expect(worktreeSymlinkTypeCandidates('win32', false)).toEqual(['file']) + }) + + it('makes exactly one attempt off Windows, where the type is ignored', () => { + expect(worktreeSymlinkTypeCandidates('darwin', true)).toEqual(['dir']) + expect(worktreeSymlinkTypeCandidates('linux', true)).toEqual(['dir']) + expect(worktreeSymlinkTypeCandidates('linux', false)).toEqual(['file']) + }) +}) + +describe('createWorktreeSharedPaths', () => { + let root: string + let primary: string + let worktree: string + let warn: ReturnType + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-sharedpaths-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + error = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + error.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + // Why: an APFS clone would give the worktree its own node_modules, defeating + // one-install-serves-all. Share mode must symlink even where cloning works. + posixIt('symlinks on macOS instead of APFS clone-copying', async () => { + mkdirSync(join(primary, 'node_modules')) + writeFileSync(join(primary, 'node_modules', 'marker'), 'ORIG\n') + const cloneWorktreePath = vi.fn() + + await createWorktreeSharedPaths(primary, worktree, ['node_modules'], { + platform: 'darwin', + cloneWorktreePath + }) + + expect(cloneWorktreePath).not.toHaveBeenCalled() + expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true) + }) + + posixIt('shares one directory so worktree writes reach the primary checkout', async () => { + mkdirSync(join(primary, 'node_modules')) + + await createWorktreeSharedPaths(primary, worktree, ['node_modules'], { platform: 'linux' }) + + writeFileSync(join(worktree, 'node_modules', 'installed'), 'SHARED\n') + expect(readFileSync(join(primary, 'node_modules', 'installed'), 'utf8')).toBe('SHARED\n') + }) + + posixIt('skips a path already materialized by the per-user symlink pass', async () => { + mkdirSync(join(primary, 'node_modules')) + mkdirSync(join(worktree, 'node_modules')) + + await createWorktreeSharedPaths(primary, worktree, ['node_modules'], { platform: 'linux' }) + + expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(false) + }) + + it('rejects unsafe paths without touching the filesystem', async () => { + writeFileSync(join(root, 'outside.txt'), 'DO_NOT_TOUCH') + + await createWorktreeSharedPaths(primary, worktree, ['../outside.txt', '/etc/passwd'], { + platform: 'linux' + }) + + expect(readFileSync(join(root, 'outside.txt'), 'utf8')).toBe('DO_NOT_TOUCH') + expect(warn).toHaveBeenCalled() + }) +}) + describe('createWorktreeCopiedPaths', () => { let root: string let primary: string diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts index 03e6e60b26b..e1ac1b4b2b0 100644 --- a/src/main/ipc/worktree-symlinks.ts +++ b/src/main/ipc/worktree-symlinks.ts @@ -1,5 +1,5 @@ import { symlink, mkdir, stat, lstat, unlink, cp, realpath } from 'node:fs/promises' -import { dirname, isAbsolute, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { ApfsCloneUnavailableError, canCloneWithApfs, @@ -14,6 +14,10 @@ import { type SkippedWorktreeCopyPath, type WorktreeCopyBudget } from './worktree-include-copy-budget' +import { + findExistingWorktreeSymlinkPaths, + getSafeRelativePath +} from '../git/worktree-symlink-detection' type WorktreeLinkedPathOptions = { platform?: NodeJS.Platform @@ -27,43 +31,53 @@ type WorktreeLinkedPathOptions = { // 'link': symlink when APFS clone is unavailable (user-configured shared paths). // 'copy': real copy when APFS clone is unavailable (.worktreeinclude paths, which // are per-worktree copies by cross-tool convention — edits must not leak back). -type WorktreeMaterializeMode = 'link' | 'copy' +// 'share': always symlink (orca.yaml sharedDirectories). An APFS clone would give +// each worktree an independent node_modules, defeating one-install-serves-all. +type WorktreeMaterializeMode = 'link' | 'copy' | 'share' -type SafeRelativePathResult = - | { - safe: true - rel: string - } - | { - safe: false - } - -function getSafeRelativePath(rawPath: string): SafeRelativePathResult { - // Why: strip leading separators (both `/` and `\`) before the guard so - // Windows-style input like `\foo` is normalized the same way POSIX `/foo` - // is, and the traversal check below sees the already-relative form. - const rel = rawPath.trim().replace(/^[\\/]+/, '') - // Why: split on both separators so a Windows-authored `..\escape` is - // rejected the same way POSIX `../escape` is. `path.isAbsolute` catches - // drive-letter absolutes (`C:\...`); the split catches relative - // backslash traversal that `.split('/')` would otherwise miss. - if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) { - return { safe: false } +/** The `fs.symlink` types to attempt, in order, for one materialized path. + * + * Why more than one on Windows: a plain symlink needs Developer Mode or admin, + * so an ordinary Windows user gets EPERM and silently ends up with no shared + * directory at all. A directory junction needs no privilege, so try it first. + * + * Why still fall back to a symlink: a junction cannot point at a UNC path, and + * a WSL project's repo lives behind one (`\\wsl.localhost\\...`). The + * fallback keeps that case working exactly as it does today. */ +export function worktreeSymlinkTypeCandidates( + platform: NodeJS.Platform, + sourceIsDirectory: boolean +): ('junction' | 'dir' | 'file')[] { + if (!sourceIsDirectory) { + return ['file'] } - return { safe: true, rel } + return platform === 'win32' ? ['junction', 'dir'] : ['dir'] } async function symlinkWorktreePath( source: string, target: string, - sourceIsDirectory: boolean + sourceIsDirectory: boolean, + platform: NodeJS.Platform ): Promise { await mkdir(dirname(target), { recursive: true }) - // Why: Windows requires an explicit `type` ('dir' vs 'file' vs - // 'junction') for `fs.symlink`. On POSIX the argument is ignored, so - // passing it unconditionally is safe and removes a Windows-only - // failure mode when Node can't auto-detect from the source. - await symlink(source, target, sourceIsDirectory ? 'dir' : 'file') + // Why: Windows requires an explicit `type` ('dir' vs 'file' vs 'junction') + // for `fs.symlink`. On POSIX the argument is ignored, so passing it + // unconditionally is safe and removes a Windows-only failure mode when Node + // can't auto-detect from the source. + const candidates = worktreeSymlinkTypeCandidates(platform, sourceIsDirectory) + for (let index = 0; index < candidates.length; index++) { + try { + // Why: `source` is always absolute (`resolve()` guarantees it), which a + // junction requires. + await symlink(source, target, candidates[index]) + return + } catch (error) { + if (index === candidates.length - 1) { + throw error + } + } + } } async function copyWorktreePath(source: string, target: string): Promise { @@ -93,7 +107,13 @@ async function createWorktreeLinkedPath( apfsFilesystemCache: DarwinFilesystemCache, realCopyFallbackAllowed: () => boolean ): Promise { - if (options.platform === 'darwin' && (!sourceIsSymbolicLink || mode === 'copy')) { + // Why: share mode must never clone — an independent copy would give each + // worktree its own node_modules, defeating one-install-serves-all. + if ( + mode !== 'share' && + options.platform === 'darwin' && + (!sourceIsSymbolicLink || mode === 'copy') + ) { try { const cloneWorktreePath = options.cloneWorktreePath ?? @@ -130,7 +150,7 @@ async function createWorktreeLinkedPath( await copyWorktreePath(copySource, target) return } - await symlinkWorktreePath(source, target, sourceIsDirectory) + await symlinkWorktreePath(source, target, sourceIsDirectory, options.platform ?? process.platform) } /** Whether this copy will land as an APFS clone rather than a byte-for-byte @@ -312,6 +332,19 @@ export async function createWorktreeCopiedPaths( return await materializeWorktreePaths(primaryPath, worktreePath, paths, 'copy', options) } +/** Symlink `orca.yaml` `worktree.sharedDirectories` into a freshly-created + * worktree. Unlike createWorktreeLinkedPaths this never APFS clone-copies: a + * clone would give each worktree its own node_modules, and the point of a + * shared directory is that one install serves every worktree. */ +export async function createWorktreeSharedPaths( + primaryPath: string, + worktreePath: string, + paths: readonly string[], + options: WorktreeLinkedPathOptions = {} +): Promise { + await materializeWorktreePaths(primaryPath, worktreePath, paths, 'share', options) +} + /** Create filesystem symlinks from the primary checkout into a freshly-created * worktree for each configured path. Failures on individual paths are logged * and skipped so a missing/stale entry never blocks worktree creation. @@ -350,26 +383,7 @@ export async function removeWorktreeLinkedPaths( } } -export async function findExistingWorktreeSymlinkPaths( - worktreePath: string, - paths: readonly string[] -): Promise { - const symlinkPaths: string[] = [] - for (const rawPath of paths) { - const safePath = getSafeRelativePath(rawPath) - if (!safePath.safe) { - continue - } - try { - if ((await lstat(resolve(worktreePath, safePath.rel))).isSymbolicLink()) { - symlinkPaths.push(safePath.rel) - } - } catch { - // Why: only a positively identified symlink may bypass dirty preflight. - } - } - return symlinkPaths -} +export { findExistingWorktreeSymlinkPaths } /** Remove previously-created symlinks from a worktree before deletion. * diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 4879edcdb08..646b5cc3a12 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -4,7 +4,9 @@ import type * as GitUsernameModule from '../git/git-username' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' +import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' +import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' +import { toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -265,10 +267,18 @@ import { const ORIGIN_REMOTE_URL = 'git@github.com:org/repo.git' const ORIGIN_HEAD_COMPONENT = reviewHeadRemoteRefComponent('origin', ORIGIN_REMOTE_URL) import { + DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS, + LINEAGE_HYDRATION_TIMEOUT_MS, __getDetectedWorktreeScanCacheStatsForTests, __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' +import { + getSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' type HandlerMap = Record unknown> @@ -280,6 +290,7 @@ describe('registerWorktreeHandlers', () => { send: vi.fn() } } + const ipcEvent = { sender: { id: 1 } } const store = { getRepos: vi.fn(), getRepo: vi.fn(), @@ -292,7 +303,10 @@ describe('registerWorktreeHandlers', () => { getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), getAllWorktreeLineage: vi.fn(), - removeWorktreeLineage: vi.fn() + removeWorktreeLineage: vi.fn(), + getAllWorkspaceLineage: vi.fn(), + getFolderWorkspaces: vi.fn(), + getProjectGroups: vi.fn() } let runtimeStub: { resolveRemoteTrackingBase: ReturnType @@ -310,12 +324,15 @@ describe('registerWorktreeHandlers', () => { notifyWorktreesChangedForRemoteClients: ReturnType closeFileWatchersForRemoval: ReturnType acquireFileWatcherRemoval: ReturnType + hydrateInferredWorktreeLineage: ReturnType } beforeEach(() => { setPlatform(ORIGINAL_PLATFORM) + clearConfiguredWorktreeSharedDirectoriesCacheForTests() __resetSshWorktreeCreateFetchCacheForTests() __resetDetectedWorktreeScanCacheForTests() + resetSshProviderAuthorities() invalidateAuthorizedRootsCache() for (const m of [ handleMock, @@ -368,6 +385,9 @@ describe('registerWorktreeHandlers', () => { store.removeWorktreeMeta, store.getAllWorktreeLineage, store.removeWorktreeLineage, + store.getAllWorkspaceLineage, + store.getFolderWorkspaces, + store.getProjectGroups, killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, @@ -432,6 +452,9 @@ describe('registerWorktreeHandlers', () => { } ]) store.getAllWorktreeLineage.mockReturnValue({}) + store.getAllWorkspaceLineage.mockReturnValue({}) + store.getFolderWorkspaces.mockReturnValue([]) + store.getProjectGroups.mockReturnValue([]) resolveLocalGitUsernameMock.mockResolvedValue('') getBaseRefDefaultMock.mockResolvedValue('origin/main') resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main') @@ -522,7 +545,8 @@ describe('registerWorktreeHandlers', () => { }), notifyWorktreesChangedForRemoteClients: vi.fn(), closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), - acquireFileWatcherRemoval: vi.fn() + acquireFileWatcherRemoval: vi.fn(), + hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined) } runtimeStub.acquireFileWatcherRemoval.mockImplementation( async (worktreePath: string, connectionId?: string) => { @@ -896,6 +920,35 @@ describe('registerWorktreeHandlers', () => { }) }) + it('keeps an emoji-only display name while using safe branch and path names', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/rocket', + head: 'abc123', + branch: 'rocket', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: '🚀' + }) + + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/rocket', + 'rocket', + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/rocket', + expect.objectContaining({ displayName: '🚀' }) + ) + }) + it('uses a repo-specific worktree base path when creating local worktrees', async () => { store.getRepo.mockReturnValue({ id: 'repo-1', @@ -2367,6 +2420,963 @@ describe('registerWorktreeHandlers', () => { }) }) + it('selects the exact SSH repo owner when repo IDs collide across hosts', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const localRepo = { + id: 'shared-repo', + path: '/local/repo', + displayName: 'local repo', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + ...localRepo, + path: '/remote/repo', + displayName: 'remote repo', + connectionId: 'target-a' + } + const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } + store.getRepos.mockImplementation(() => [{ ...localRepo }, { ...sshRepo }]) + getSshGitProviderMock.mockImplementation((targetId) => + targetId === 'target-a' ? provider : undefined + ) + const expectedAuthority = getSshProviderAuthority('target-a') + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: sshHostId, + expectedAuthority + }) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo', { + signal: expect.any(AbortSignal) + }) + expect(result).toEqual({ + status: 'complete', + providerRequestId: 'request-1', + repoId: 'shared-repo', + authority: { + kind: 'direct-ssh', + executionHostId: sshHostId, + ...expectedAuthority + }, + result: { + repoId: 'shared-repo', + authoritative: true, + source: 'git', + worktrees: [] + } + }) + }) + + it('rejects malformed and contradictory repo host provenance', async () => { + const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } + getSshGitProviderMock.mockReturnValue(provider) + const request = { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + } + const baseRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + + store.getRepos.mockReturnValue([{ ...baseRepo, executionHostId: 'ssh:%' }]) + const malformed = await handlers['worktrees:listDetected'](ipcEvent, request) + + store.getRepos.mockReturnValue([ + { + ...baseRepo, + executionHostId: toSshExecutionHostId('target-b') + } + ]) + const contradictory = await handlers['worktrees:listDetected'](ipcEvent, request) + + expect(malformed).toMatchObject({ + status: 'rejected', + providerRequestId: 'request-1', + executionHostId: 'ssh:target-a' + }) + expect(contradictory).toMatchObject({ + status: 'rejected', + providerRequestId: 'request-1', + executionHostId: 'ssh:target-a' + }) + expect(provider.listWorktrees).not.toHaveBeenCalled() + }) + + it('returns a local discriminant without SSH authority fields', async () => { + listWorktreesMock.mockResolvedValue([]) + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'repo-1', + executionHostId: 'local' + }) + + expect(result).toEqual({ + status: 'complete', + providerRequestId: 'request-1', + repoId: 'repo-1', + authority: { kind: 'local', executionHostId: 'local' }, + result: { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [] + } + }) + }) + + it('includes the full SSH authority on non-authoritative data', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(undefined) + const expectedAuthority = getSshProviderAuthority('target-a') + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + + expect(result).toEqual({ + status: 'non-authoritative', + providerRequestId: 'request-1', + repoId: 'repo-1', + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...expectedAuthority + }, + result: { + repoId: 'repo-1', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + }) + }) + + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { + const sshRepo = { + id: 'shared-repo', + path: '/remote/repo-a', + displayName: 'remote repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const duplicateSshRepo = { ...sshRepo, path: '/remote/repo-b' } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + store.getRepos.mockReturnValue([sshRepo, duplicateSshRepo, localRepo]) + const expectedAuthority = getSshProviderAuthority('target-a') + + const qualified = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + const legacy = await handlers['worktrees:listDetected'](null, { repoId: 'shared-repo' }) + + expect(qualified).toMatchObject({ + status: 'ambiguous-owner', + providerRequestId: 'request-1', + executionHostId: 'ssh:target-a' + }) + expect(legacy).toEqual({ + repoId: 'shared-repo', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('does not prune another host lineage when repo IDs collide', async () => { + const sshARepo = { + id: 'shared-repo', + path: '/remote/repo-a', + displayName: 'remote repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const sshBRepo = { + ...sshARepo, + path: '/remote/repo-b', + displayName: 'remote repo B', + connectionId: 'target-b' + } + const childId = 'shared-repo::/remote/repo-b/feature' + store.getRepos.mockReturnValue([sshARepo, sshBRepo]) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === childId + ? makeWorktreeMeta({ hostId: toSshExecutionHostId('target-b') }) + : undefined + ) + store.getAllWorktreeLineage.mockReturnValue({ + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'shared-repo::/remote/repo-b', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn().mockResolvedValue([]) }) + const expectedAuthority = getSshProviderAuthority('target-a') + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + + expect(result).toMatchObject({ status: 'complete' }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('preserves conflicting host metadata instead of backfilling it', async () => { + const sshARepo = { + id: 'shared-repo', + path: '/remote/repo-a', + displayName: 'remote repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const sshBRepo = { ...sshARepo, path: '/remote/repo-b', connectionId: 'target-b' } + const worktreePath = '/remote/shared-feature' + store.getRepos.mockReturnValue([sshARepo, sshBRepo]) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => + worktreeId === `shared-repo::${worktreePath}` + ? makeWorktreeMeta({ hostId: toSshExecutionHostId('target-b') }) + : undefined + ) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'head-a', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + }) + + const result = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: 'shared-repo', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + + expect(result).toMatchObject({ + status: 'non-authoritative', + result: { authoritative: false, worktrees: [] } + }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('rejects runtime hosts, wrong SSH targets, and missing authority', async () => { + const requestId = 'request-1' as ProviderRequestId + const expectedAuthority = getSshProviderAuthority('target-a') + + const runtimeResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: 'runtime:runtime-a' + }) + const wrongTargetResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-b'), + expectedAuthority + }) + const missingAuthorityResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a') + }) + const zeroOwnerResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b', + executionHostId: toSshExecutionHostId('target-a') + } + ]) + const wrongProviderOwnerResult = await handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: requestId, + repoId: 'repo-1', + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + + expect(runtimeResult).toMatchObject({ status: 'rejected' }) + expect(wrongTargetResult).toMatchObject({ status: 'rejected' }) + expect(missingAuthorityResult).toMatchObject({ status: 'rejected' }) + expect(zeroOwnerResult).toMatchObject({ status: 'ambiguous-owner' }) + expect(wrongProviderOwnerResult).toMatchObject({ status: 'rejected' }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('rejects a provider replacement during the SSH await without durable mutations', async () => { + let resolveList: (worktrees: GitWorktreeInfo[]) => void = () => {} + const firstProvider = { + listWorktrees: vi.fn( + () => + new Promise((resolve) => { + resolveList = resolve + }) + ) + } + const replacementProvider = { listWorktrees: vi.fn().mockResolvedValue([]) } + let currentProvider = firstProvider + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockImplementation(() => currentProvider) + const expectedAuthority = getSshProviderAuthority('target-a') + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority + }) + await Promise.resolve() + currentProvider = replacementProvider + resolveList([ + { + path: '/remote/repo', + head: 'stale-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await expect(pending).resolves.toMatchObject({ status: 'stale' }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it.each([ + ['malformed', 'ssh:%'], + ['contradictory', toSshExecutionHostId('target-b')] + ])( + 'rejects %s repo provenance introduced during the SSH await', + async (_caseName, invalidExecutionHostId) => { + let resolveList: (worktrees: GitWorktreeInfo[]) => void = () => {} + const provider = { + listWorktrees: vi.fn( + () => + new Promise((resolve) => { + resolveList = resolve + }) + ) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + let repos: Repo[] = [sshRepo] + store.getRepos.mockImplementation(() => repos) + getSshGitProviderMock.mockReturnValue(provider) + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: `request-${_caseName}` as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + repos = [ + sshRepo, + { + ...sshRepo, + path: '/remote/conflicting-repo', + executionHostId: invalidExecutionHostId as Repo['executionHostId'] + } + ] + resolveList([ + { + path: '/remote/repo', + head: 'stale-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await expect(pending).resolves.toMatchObject({ status: 'stale' }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + } + ) + + it('aborts all old-authority SSH calls on rotation with target isolation', async () => { + const repos = [ + { + id: 'repo-a', + path: '/remote/repo-a', + displayName: 'repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + }, + { + id: 'repo-b', + path: '/remote/repo-b', + displayName: 'repo B', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b' + } + ] + const resolveA: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + let resolveB: (worktrees: GitWorktreeInfo[]) => void = () => {} + const signalsA: AbortSignal[] = [] + let signalB: AbortSignal | undefined + const abortsA = [vi.fn(), vi.fn()] + const abortB = vi.fn() + const providerA = { + listWorktrees: vi.fn((_path: string, options?: { signal?: AbortSignal }) => { + const index = signalsA.length + const signal = options?.signal + if (signal) { + signalsA.push(signal) + signal.addEventListener('abort', abortsA[index]) + } + return new Promise((resolve) => { + resolveA.push(resolve) + }) + }) + } + const providerB = { + listWorktrees: vi.fn((_path: string, options?: { signal?: AbortSignal }) => { + signalB = options?.signal + signalB?.addEventListener('abort', abortB) + return new Promise((resolve) => { + resolveB = resolve + }) + }) + } + store.getRepos.mockReturnValue(repos) + getSshGitProviderMock.mockImplementation((targetId) => + targetId === 'target-a' ? providerA : providerB + ) + const authorityA = getSshProviderAuthority('target-a') + const authorityB = getSshProviderAuthority('target-b') + const request = ( + repo: (typeof repos)[number], + providerRequestId: ProviderRequestId, + expectedAuthority: ReturnType + ) => + handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId, + repoId: repo.id, + executionHostId: toSshExecutionHostId(repo.connectionId), + expectedAuthority + }) + + const pendingA1 = request(repos[0], 'request-a1' as ProviderRequestId, authorityA) + const pendingA2 = request(repos[0], 'request-a2' as ProviderRequestId, authorityA) + const pendingB = request(repos[1], 'request-b' as ProviderRequestId, authorityB) + await Promise.resolve() + + rotateSshProviderAuthority('target-a') + rotateSshProviderAuthority('target-a') + + expect(signalsA).toHaveLength(2) + expect(signalsA.every((signal) => signal.aborted)).toBe(true) + expect(abortsA[0]).toHaveBeenCalledOnce() + expect(abortsA[1]).toHaveBeenCalledOnce() + expect(signalB?.aborted).toBe(false) + expect(abortB).not.toHaveBeenCalled() + await expect(Promise.all([pendingA1, pendingA2])).resolves.toEqual([ + expect.objectContaining({ status: 'canceled', providerRequestId: 'request-a1' }), + expect.objectContaining({ status: 'canceled', providerRequestId: 'request-a2' }) + ]) + + resolveB([]) + await expect(pendingB).resolves.toMatchObject({ + status: 'complete', + providerRequestId: 'request-b' + }) + rotateSshProviderAuthority('target-b') + expect(abortB).not.toHaveBeenCalled() + + store.setWorktreeMeta.mockClear() + store.removeWorktreeLineage.mockClear() + for (const resolve of resolveA) { + resolve([ + { + path: '/remote/repo-a', + head: 'late-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + } + await Promise.resolve() + await Promise.resolve() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('cancels an SSH provider request by sender-scoped provider request ID', async () => { + let providerSignal: AbortSignal | undefined + const provider = { + listWorktrees: vi.fn( + (_repoPath: string, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + providerSignal = options?.signal + providerSignal?.addEventListener( + 'abort', + () => reject(new DOMException('Canceled', 'AbortError')), + { once: true } + ) + }) + ) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + handlers['worktrees:cancelListDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId + }) + + expect(providerSignal?.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ + status: 'canceled', + providerRequestId: 'request-1' + }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('settles a noncooperative SSH provider at the main-owned deadline and cleans up', async () => { + vi.useFakeTimers() + try { + let providerSignal: AbortSignal | undefined + let rejectLateRequest: (error: Error) => void = () => {} + const provider = { + listWorktrees: vi.fn((_repoPath: string, options?: { signal?: AbortSignal }) => { + if (provider.listWorktrees.mock.calls.length > 1) { + return Promise.resolve([]) + } + return new Promise((_resolve, reject) => { + rejectLateRequest = reject + // Why: this provider intentionally ignores abort to exercise the main-owned deadline. + if (options?.signal) { + providerSignal = options?.signal + } + }) + }) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS - 1) + let settled = false + void Promise.resolve(pending).finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + + expect(providerSignal?.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ + status: 'timed-out', + providerRequestId: 'request-1' + }) + expect(vi.getTimerCount()).toBe(0) + + await expect( + handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + ).resolves.toMatchObject({ + status: 'complete', + providerRequestId: 'request-1' + }) + + store.setWorktreeMeta.mockClear() + store.removeWorktreeLineage.mockClear() + rejectLateRequest(new Error('late provider failure')) + await Promise.resolve() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('filters worktree and folder lineage to one exact SSH host', async () => { + const repos = [ + { + id: 'duplicate', + path: '/a/repo', + displayName: 'a', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + projectGroupId: 'group-a' + }, + { + id: 'duplicate', + path: '/b/repo', + displayName: 'b', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b', + projectGroupId: 'group-b' + } + ] + const aParent = 'duplicate::/a/repo' + const aChild = 'duplicate::/a/child' + const bParent = 'duplicate::/b/repo' + const bChild = 'duplicate::/b/child' + const runtimeParent = 'duplicate::/runtime/parent' + const runtimeChild = 'duplicate::/runtime/child' + const worktreeLineage = { + [aChild]: { + worktreeId: aChild, + worktreeInstanceId: 'a-child', + parentWorktreeId: aParent, + parentWorktreeInstanceId: 'a-parent', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + }, + [bChild]: { + worktreeId: bChild, + worktreeInstanceId: 'b-child', + parentWorktreeId: bParent, + parentWorktreeInstanceId: 'b-parent', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 2 + }, + [runtimeChild]: { + worktreeId: runtimeChild, + worktreeInstanceId: 'runtime-child', + parentWorktreeId: runtimeParent, + parentWorktreeInstanceId: 'runtime-parent', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 3 + } + } + const folderLineage = { + 'folder:folder-a-child': { + childWorkspaceKey: 'folder:folder-a-child', + parentWorkspaceKey: 'folder:folder-a-parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 3 + }, + 'folder:folder-b-child': { + childWorkspaceKey: 'folder:folder-b-child', + parentWorkspaceKey: 'folder:folder-b-parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 4 + } + } + store.getRepos.mockReturnValue(repos) + store.getWorktreeMeta.mockImplementation((id: string) => + id.includes('/runtime/') + ? { hostId: 'ssh:target-a', runtimeOwnerEnvironmentId: 'environment-a' } + : { + hostId: id.includes('/a/') || id.endsWith('/a/repo') ? 'ssh:target-a' : 'ssh:target-b' + } + ) + store.getAllWorktreeLineage.mockReturnValue(worktreeLineage) + store.getAllWorkspaceLineage.mockReturnValue(folderLineage) + store.getProjectGroups.mockReturnValue([ + { id: 'group-a', connectionId: 'target-a' }, + { id: 'group-b', connectionId: 'target-b' } + ]) + store.getFolderWorkspaces.mockReturnValue([ + { + id: 'folder-a-child', + projectGroupId: 'group-a', + folderPath: '/a/child', + connectionId: 'target-a' + }, + { + id: 'folder-a-parent', + projectGroupId: 'group-a', + folderPath: '/a', + connectionId: 'target-a' + }, + { + id: 'folder-b-child', + projectGroupId: 'group-b', + folderPath: '/b/child', + connectionId: 'target-b' + }, + { + id: 'folder-b-parent', + projectGroupId: 'group-b', + folderPath: '/b', + connectionId: 'target-b' + } + ]) + const provider = { listWorktrees: vi.fn() } + getSshGitProviderMock.mockImplementation((targetId: string) => + targetId === 'target-a' ? provider : undefined + ) + + const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + + expect(result).toMatchObject({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + targetId: 'target-a' + }, + worktreeLineageById: { [aChild]: worktreeLineage[aChild] }, + workspaceLineageByChildKey: { + 'folder:folder-a-child': folderLineage['folder:folder-a-child'] + } + }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('snapshots lineage catalogs once and memoizes repeated owner resolution', async () => { + const worktreeIds = Array.from( + { length: 101 }, + (_, index) => `repo-1::/workspace/repo-${index}` + ) + const lineage = Object.fromEntries( + worktreeIds.slice(1).map((worktreeId, index) => [ + worktreeId, + { + worktreeId, + worktreeInstanceId: `child-${index}`, + parentWorktreeId: worktreeIds[index], + parentWorktreeInstanceId: `parent-${index}`, + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' }, + createdAt: index + } + ]) + ) + store.getAllWorktreeLineage.mockReturnValue(lineage) + store.getRepos.mockClear() + store.getFolderWorkspaces.mockClear() + store.getProjectGroups.mockClear() + store.getWorktreeMeta.mockClear() + + const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: 'local' + }) + + expect(result).toMatchObject({ authoritative: true }) + expect( + Object.keys((result as { worktreeLineageById: Record }).worktreeLineageById) + ).toHaveLength(100) + expect(store.getRepos).toHaveBeenCalledOnce() + expect(store.getFolderWorkspaces).toHaveBeenCalledOnce() + expect(store.getProjectGroups).toHaveBeenCalledOnce() + expect(store.getWorktreeMeta).toHaveBeenCalledTimes(101) + }) + + it('preserves ambiguous legacy lineage instead of guessing among duplicate repo owners', async () => { + const child = 'duplicate::/child' + const parent = 'duplicate::/parent' + store.getRepos.mockReturnValue([ + { + id: 'duplicate', + path: '/local', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + }, + { + id: 'duplicate', + path: '/remote', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + ]) + store.getAllWorktreeLineage.mockReturnValue({ + [child]: { + worktreeId: child, + worktreeInstanceId: 'child', + parentWorktreeId: parent, + parentWorktreeInstanceId: 'parent', + origin: 'cli', + capture: { source: 'cwd-context', confidence: 'inferred' }, + createdAt: 1 + } + }) + + await expect( + handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) + ).resolves.toEqual({ + authoritative: false, + executionHostId: 'local', + reason: 'ambiguous-owner' + }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('rejects runtime lineage reads and stale SSH authority after hydration', async () => { + await expect( + handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: 'runtime:environment-a' + }) + ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) + expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() + + let finishHydration: () => void = () => {} + runtimeStub.hydrateInferredWorktreeLineage.mockImplementation( + () => + new Promise((resolve) => { + finishHydration = resolve + }) + ) + getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn() }) + const authority = getSshProviderAuthority('target-a') + const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: authority + }) + await Promise.resolve() + rotateSshProviderAuthority('target-a') + finishHydration() + + await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + }) + + it('bounds noncooperative lineage hydration and permits a later same-authority read', async () => { + vi.useFakeTimers() + try { + runtimeStub.hydrateInferredWorktreeLineage.mockReturnValue(new Promise(() => {})) + getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn() }) + const authority = getSshProviderAuthority('target-a') + const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: authority + }) + await vi.advanceTimersByTimeAsync(LINEAGE_HYDRATION_TIMEOUT_MS - 1) + let settled = false + void Promise.resolve(pending).finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toMatchObject({ + authoritative: false, + reason: 'unavailable' + }) + expect(vi.getTimerCount()).toBe(0) + + runtimeStub.hydrateInferredWorktreeLineage.mockResolvedValue(undefined) + await expect( + handlers['worktrees:listLineageForHost'](ipcEvent, { + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: authority + }) + ).resolves.toMatchObject({ authoritative: true }) + } finally { + vi.useRealTimers() + } + }) + it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { const parentPath = '/workspace/assigned-issues' const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing' @@ -2928,14 +3938,6 @@ describe('registerWorktreeHandlers', () => { }) it('does not accumulate scan bookkeeping across prolonged repository churn', async () => { - store.getRepo.mockImplementation((repoId: string) => ({ - id: repoId, - path: `/workspace/${repoId}`, - displayName: repoId, - badgeColor: '#000', - addedAt: 0, - worktreeBaseRef: null - })) listWorktreesMock.mockImplementation(async (repoPath: string) => [ { path: repoPath, @@ -2948,6 +3950,16 @@ describe('registerWorktreeHandlers', () => { for (let index = 0; index < 128; index += 1) { const repoId = `repo-${index}` + store.getRepos.mockReturnValue([ + { + id: repoId, + path: `/workspace/${repoId}`, + displayName: repoId, + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + ]) await handlers['worktrees:listDetected'](null, { repoId }) notifyWorktreesChanged(mainWindow as never, repoId) } @@ -7141,6 +8153,29 @@ describe('registerWorktreeHandlers', () => { ) }) + it('passes project shared links through the IPC removal preflight and cleanup', async () => { + mockKnownFeatureWorktree() + loadHooksMock.mockReturnValue({ + worktree: { sharedDirectories: ['node_modules'] } + }) + findExistingWorktreeSymlinkPathsMock.mockResolvedValue(['node_modules']) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(findExistingWorktreeSymlinkPathsMock).toHaveBeenCalledWith('/workspace/feature-wt', [ + 'node_modules' + ]) + expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith('/workspace/feature-wt', false, { + ignoredUntrackedPaths: ['node_modules'] + }) + expect(removeWorktreeLinkedPathsMock).toHaveBeenCalledWith('/workspace/feature-wt', [ + 'node_modules' + ]) + }) + it('does not remove a worktree when watcher teardown cannot release it', async () => { mockKnownFeatureWorktree() store.getRepo.mockReturnValue({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 43a0fbfbf55..64ffdf25790 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,6 +1,5 @@ /* oxlint-disable max-lines */ -import type { BrowserWindow } from 'electron' -import { ipcMain } from 'electron' +import { ipcMain, type BrowserWindow } from 'electron' import { readFile, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' @@ -13,7 +12,9 @@ import { } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' +import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' +import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { AutomationWorkspaceProvenance, @@ -30,10 +31,30 @@ import type { Repo, RemoveWorktreeResult, Worktree, + WorktreeLineage, + WorkspaceLineage, WorktreeMeta } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' -import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { + PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, + type DirectSshDetectedWorktreeRequest, + type HostQualifiedDetectedWorktreeResult, + type ListDetectedWorktreesArgs, + type ProviderRequestId +} from '../../shared/detected-worktree-provider-contract' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../../shared/host-lineage-contract' +import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import { applyMetadataFallbackVisibility, buildKnownOrcaWorkspaceLayouts, @@ -99,6 +120,7 @@ import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runti import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' +import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' @@ -108,6 +130,12 @@ import { resolveAutomationWorkspaceProvenance } from '../automations/workspace-provenance' import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority, + registerSshProviderRequestAbort +} from '../ssh/ssh-provider-authority' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -121,6 +149,8 @@ type RemoveWorktreeArgs = { skipArchive?: boolean } +type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs + async function stopPtysForDestructiveWorktreeRemoval( runtime: OrcaRuntimeService, worktreeId: string, @@ -181,7 +211,6 @@ import { stripOrcaProvenanceMetaUpdates, UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, @@ -250,7 +279,7 @@ function getProjectHostSetupMetaUpdates( ...(sameSetup && existing?.projectId !== ownership.projectId ? { projectId: ownership.projectId } : {}), - ...(sameSetup && existing?.hostId !== ownership.hostId ? { hostId: ownership.hostId } : {}), + ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), ...(existing?.projectHostSetupId === undefined ? { projectHostSetupId: ownership.projectHostSetupId } : {}) @@ -476,6 +505,8 @@ function getPreservedBranchCleanupTarget( const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() +export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 +export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 // Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 @@ -628,11 +659,18 @@ function pruneLineageForMissingRepoWorktrees( } const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) const repoPrefix = `${repo.id}::` + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + const canMutateWorktree = (worktreeId: string): boolean => { + const hostId = store.getWorktreeMeta(worktreeId)?.hostId + return hostId ? hostId === expectedHostId : repoOwners.length === 1 + } for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { const childScope = parseWorkspaceKey(childWorkspaceKey) if ( childScope?.type === 'worktree' && childScope.worktreeId.startsWith(repoPrefix) && + canMutateWorktree(childScope.worktreeId) && !liveIds.has(childScope.worktreeId) ) { if (isWorkspaceKey(childWorkspaceKey)) { @@ -641,12 +679,16 @@ function pruneLineageForMissingRepoWorktrees( } } for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { + if (childId.startsWith(repoPrefix) && canMutateWorktree(childId) && !liveIds.has(childId)) { // Why: path-derived IDs can be reused; once a scan proves the child is gone, drop its lineage so a future same-path worktree can't inherit it. store.removeWorktreeLineage(childId) store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) } - if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) { + if ( + lineage.parentWorktreeId.startsWith(repoPrefix) && + canMutateWorktree(lineage.parentWorktreeId) && + !liveIds.has(lineage.parentWorktreeId) + ) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { // Why: keep child lineage for the "Missing parent" UI, but rotate the absent parent's identity once so a path reuse can't inherit it. @@ -708,7 +750,15 @@ function listDisconnectedSshWorktrees( metaIndex: SshWorktreeMetaIndex ): ReturnType[] { const byWorktreeId = new Map>() + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) for (const candidate of metaIndex.get(repo.id) ?? []) { + if ( + (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || + (!candidate.meta.hostId && repoOwners.length > 1) + ) { + continue + } const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) const meta = Object.keys(ownershipUpdates).length > 0 @@ -984,16 +1034,718 @@ function buildDisconnectedDetectedWorktrees( return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } +function hasConflictingStoredWorktreeOwner( + store: Store, + repo: Repo, + worktreeIds: readonly string[] +): boolean { + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + return worktreeIds.some((worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) + }) +} + +type RepoOwnershipEvidence = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'malformed' } + | { status: 'contradictory' } + +function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return { status: 'malformed' } + } + const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined + const connectionId = hasConnection ? repo.connectionId?.trim() : null + if (hasConnection && !connectionId) { + return { status: 'malformed' } + } + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { + return { status: 'contradictory' } + } + return { + status: 'owned', + hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID + } +} + +function findExactRepoOwner( + store: Store, + repoId: string, + executionHostId?: ExecutionHostId +): Repo | undefined { + const candidates = store.getRepos().filter((repo) => repo.id === repoId) + const evidence = candidates.map(resolveRepoOwnershipEvidence) + if (evidence.some((owner) => owner.status !== 'owned')) { + return undefined + } + const matches = candidates.filter((_, index) => { + const owner = evidence[index] + return ( + owner?.status === 'owned' && + (executionHostId === undefined || owner.hostId === executionHostId) + ) + }) + return matches.length === 1 ? matches[0] : undefined +} + +function isCapturedRepoCurrent( + store: Store, + repo: Repo, + executionHostId?: ExecutionHostId +): boolean { + const current = findExactRepoOwner(store, repo.id, executionHostId) + return ( + current !== undefined && + current.path === repo.path && + (current.connectionId ?? null) === (repo.connectionId ?? null) && + (current.executionHostId ?? null) === (repo.executionHostId ?? null) + ) +} + +async function listDetectedWorktreesForCapturedRepo( + store: Store, + repo: Repo, + isCurrent: () => boolean, + capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const abortedResult = () => + providerAbort?.signal.aborted + ? ({ providerAbortStatus: providerAbort.status() } as const) + : undefined + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees: GitWorktreeInfo[] + let freshScan = true + if (isFolderRepo(repo)) { + if (!isCurrent()) { + return null + } + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + } + } + if (repo.connectionId) { + if (!capturedProvider) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + gitWorktrees = await capturedProvider.listWorktrees(repo.path, { + signal: providerAbort?.signal + }) + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) + if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) + } + } catch (err) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + if (repo.connectionId) { + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } + } +} + +function hasValidDirectSshAuthority( + args: DirectSshDetectedWorktreeRequest +): args is DirectSshDetectedWorktreeRequest { + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +function hasValidLineageSshAuthority( + args: ListDesktopLineageForHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +type LineageOwner = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'ambiguous' | 'contradictory' | 'runtime' } + +type LineageFolder = ReturnType[number] +type LineageGroup = ReturnType[number] + +type LineageResolutionContext = { + store: Store + repos: Repo[] + groups: LineageGroup[] + reposById: Map + foldersById: Map + groupsById: Map + groupSubtreeIdsByRoot: Map> + worktreeOwners: Map + folderOwners: Map + workspaceOwners: Map +} + +function indexLineageEntriesById( + entries: readonly T[] +): Map { + const index = new Map() + for (const entry of entries) { + const matching = index.get(entry.id) ?? [] + matching.push(entry) + index.set(entry.id, matching) + } + return index +} + +function createLineageResolutionContext(store: Store): LineageResolutionContext { + const repos = store.getRepos() + const folders = store.getFolderWorkspaces() + const groups = store.getProjectGroups() + return { + store, + repos, + groups, + reposById: indexLineageEntriesById(repos), + foldersById: indexLineageEntriesById(folders), + groupsById: indexLineageEntriesById(groups), + groupSubtreeIdsByRoot: new Map(), + worktreeOwners: new Map(), + folderOwners: new Map(), + workspaceOwners: new Map() + } +} + +function resolveRepoLineageOwner(repo: Repo): LineageOwner { + const owner = resolveRepoOwnershipEvidence(repo) + if (owner.status === 'malformed') { + return { status: 'ambiguous' } + } + if (owner.status === 'contradictory') { + return { status: 'contradictory' } + } + return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner +} + +function resolveWorktreeLineageOwner( + context: LineageResolutionContext, + worktreeId: string +): LineageOwner { + const cached = context.worktreeOwners.get(worktreeId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.worktreeOwners.set(worktreeId, owner) + return owner + } + let repoId: string + try { + repoId = parseWorktreeId(worktreeId).repoId + } catch { + return remember({ status: 'ambiguous' }) + } + const repos = context.reposById.get(repoId) ?? [] + const meta = context.store.getWorktreeMeta(worktreeId) + const runtimeOwnerEnvironmentId = ( + meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined + )?.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwnerEnvironmentId) { + return remember({ status: 'runtime' }) + } + if (meta?.hostId) { + const explicitHost = parseExecutionHostId(meta.hostId) + if (!explicitHost) { + return remember({ status: 'ambiguous' }) + } + if (explicitHost.kind === 'runtime') { + return remember({ status: 'runtime' }) + } + const matchingRepos = repos.filter((repo) => { + const owner = resolveRepoLineageOwner(repo) + return owner.status === 'owned' && owner.hostId === explicitHost.id + }) + if (matchingRepos.length === 1) { + return remember({ status: 'owned', hostId: explicitHost.id }) + } + return remember( + matchingRepos.length > 1 + ? { status: 'ambiguous' } + : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } + ) + } + if (repos.length !== 1) { + return remember({ status: 'ambiguous' }) + } + return remember(resolveRepoLineageOwner(repos[0])) +} + +function getFolderLineageCandidateRepos( + context: LineageResolutionContext, + folder: LineageFolder +): Repo[] { + let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) + if (!groupIds) { + groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) + context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) + } + const grouped = context.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = context.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + const group = context.groupsById.get(folder.projectGroupId)?.[0] + const connectionId = folder.connectionId ?? group?.connectionId ?? null + return connectionId + ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + : grouped.length > 0 + ? [ + ...grouped, + ...pathRepos.filter((repo) => + new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( + repo.connectionId ?? null + ) + ) + ] + : pathRepos +} + +function resolveFolderLineageOwner( + context: LineageResolutionContext, + folderWorkspaceId: string +): LineageOwner { + const cached = context.folderOwners.get(folderWorkspaceId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.folderOwners.set(folderWorkspaceId, owner) + return owner + } + const folders = context.foldersById.get(folderWorkspaceId) ?? [] + if (folders.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const folder = folders[0] + const groups = context.groupsById.get(folder.projectGroupId) ?? [] + if (groups.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const group = groups[0] + const hosts = new Set() + if (folder.connectionId) { + hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) + } + if (group.connectionId) { + hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) + } + if (group.executionHostId) { + const parsed = parseExecutionHostId(group.executionHostId) + if (!parsed) { + return remember({ status: 'ambiguous' }) + } + hosts.add(parsed.id) + } + for (const repo of getFolderLineageCandidateRepos(context, folder)) { + const owner = resolveRepoLineageOwner(repo) + if (owner.status !== 'owned') { + return remember(owner) + } + hosts.add(owner.hostId) + } + if (hosts.size > 1) { + return remember({ status: 'contradictory' }) + } + const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID + return remember( + parseExecutionHostId(hostId)?.kind === 'runtime' + ? { status: 'runtime' } + : { status: 'owned', hostId } + ) +} + +function resolveWorkspaceLineageOwner( + context: LineageResolutionContext, + workspaceKey: string +): LineageOwner { + const cached = context.workspaceOwners.get(workspaceKey) + if (cached) { + return cached + } + const workspace = parseWorkspaceKey(workspaceKey) + const owner = !workspace + ? { status: 'ambiguous' as const } + : workspace.type === 'worktree' + ? resolveWorktreeLineageOwner(context, workspace.worktreeId) + : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) + context.workspaceOwners.set(workspaceKey, owner) + return owner +} + +function filterLineageForHost( + store: Store, + executionHostId: ExecutionHostId +): { + worktreeLineageById: Record + workspaceLineageByChildKey: Record +} | null { + const context = createLineageResolutionContext(store) + const worktreeLineageById: Record = {} + const workspaceLineageByChildKey: Record = {} + for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + const child = resolveWorktreeLineageOwner(context, worktreeId) + const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + worktreeLineageById[worktreeId] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { + const child = resolveWorkspaceLineageOwner(context, childKey) + const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + workspaceLineageByChildKey[childKey] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + return { worktreeLineageById, workspaceLineageByChildKey } +} + +async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { + let timeout: ReturnType | undefined + const hydration = Promise.resolve() + .then(() => runtime.hydrateInferredWorktreeLineage()) + .then( + () => true, + () => false + ) + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) + }) + try { + return await Promise.race([hydration, deadline]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +async function listDesktopLineageForHost( + store: Store, + runtime: OrcaRuntimeService, + args: ListDesktopLineageForHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostLineageSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + let provider: ReturnType | undefined + let authority: + | Extract['expectedAuthority'] + | null = null + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + } else { + if ( + !hasValidLineageSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + } + if (!(await hydrateLineageWithinDeadline(runtime))) { + return rejected('unavailable') + } + if ( + parsedHost.kind === 'ssh' && + (!authority || + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority)) + ) { + return rejected('stale') + } + const lineage = filterLineageForHost(store, parsedHost.id) + if (!lineage) { + return rejected('ambiguous-owner') + } + if (parsedHost.kind === 'local') { + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + ...lineage + } + } + if (!authority) { + return rejected('authority-unknown') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + ...lineage + } +} + +async function listHostQualifiedDetectedWorktrees( + store: Store, + args: ListDetectedWorktreesArgs, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const parsedHost = parseExecutionHostId(args.executionHostId) + const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status + }) + if ( + typeof args.providerRequestId !== 'string' || + args.providerRequestId.length === 0 || + Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || + !parsedHost || + parsedHost.kind === 'runtime' + ) { + return rejected('rejected') + } + let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null + if (parsedHost.kind === 'ssh') { + const directArgs = args as DirectSshDetectedWorktreeRequest + if ( + !hasValidDirectSshAuthority(directArgs) || + directArgs.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + capturedAuthority = { ...directArgs.expectedAuthority } + if (!isCurrentSshProviderAuthority(capturedAuthority)) { + return rejected('stale') + } + } + + const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) + if ( + repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') + ) { + return rejected('rejected') + } + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo) { + return rejected('ambiguous-owner') + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return rejected('rejected') + } + const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined + const isCurrent = (): boolean => { + if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { + return false + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return false + } + if (parsedHost.kind !== 'ssh') { + return true + } + return ( + capturedAuthority !== null && + getSshGitProvider(parsedHost.targetId) === provider && + isCurrentSshProviderAuthority(capturedAuthority) + ) + } + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + isCurrent, + provider, + providerAbort + ) + if (!result) { + return rejected('stale') + } + if ('providerAbortStatus' in result) { + return { + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: result.providerAbortStatus + } + } + const status = result.authoritative ? 'complete' : 'non-authoritative' + if (parsedHost.kind === 'local') { + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + if (!capturedAuthority) { + return rejected('rejected') + } + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { + kind: 'direct-ssh', + executionHostId: args.executionHostId as `ssh:${string}`, + ...capturedAuthority + }, + result + } +} + export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { + const detectedWorktreeCancellations = createSenderScopedRequestCancellations() // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') ipcMain.removeHandler('worktrees:resolvePrBase') @@ -1003,6 +1755,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') + ipcMain.removeHandler('worktrees:listLineageForHost') ipcMain.removeHandler('worktrees:updateLineage') ipcMain.removeHandler('worktrees:persistSortOrder') ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') @@ -1138,8 +1891,69 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:listDetected', - async (_event, args: { repoId: string }): Promise => { - const repo = store.getRepo(args.repoId) + async ( + event, + args: DetectedWorktreeRequestArgs + ): Promise => { + if ('executionHostId' in args) { + const parsedHost = parseExecutionHostId(args.executionHostId) + const directSshRequest = parsedHost?.kind === 'ssh' + const controller = directSshRequest + ? detectedWorktreeCancellations.begin(event, args.providerRequestId) + : null + const directArgs = args as DirectSshDetectedWorktreeRequest + const removeAuthorityAbort = + controller && + parsedHost?.kind === 'ssh' && + hasValidDirectSshAuthority(directArgs) && + directArgs.expectedAuthority.targetId === parsedHost.targetId + ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) + : undefined + let timedOut = false + let removeAbortListener: (() => void) | undefined + const abortedResult = controller + ? new Promise((resolve) => { + const onAbort = (): void => { + resolve({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: timedOut ? 'timed-out' : 'canceled' + }) + } + controller.signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) + }) + : undefined + const timeout = controller + ? setTimeout(() => { + timedOut = true + controller.abort() + }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) + : undefined + try { + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) + return abortedResult + ? await Promise.race([providerResult, abortedResult]) + : await providerResult + } finally { + if (timeout) { + clearTimeout(timeout) + } + removeAbortListener?.() + removeAuthorityAbort?.() + detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) + } + } + const repo = findExactRepoOwner(store, args.repoId) if (!repo) { return { repoId: args.repoId, @@ -1148,69 +1962,35 @@ export function registerWorktreeHandlers( worktrees: [] } } - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees: GitWorktreeInfo[] - let freshScan = true - if (isFolderRepo(repo)) { - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - } - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - gitWorktrees = await provider.listWorktrees(repo.path) - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) - } - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined + const authority = repo.connectionId + ? { ...getSshProviderAuthority(repo.connectionId) } + : undefined + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!repo.connectionId || + (getSshGitProvider(repo.connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) + return result && !('providerAbortStatus' in result) + ? result + : { repoId: repo.id, authoritative: false, source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + worktrees: [] } - } - return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } - } + } + ) + ipcMain.handle( + 'worktrees:cancelListDetected', + (event, args: { providerRequestId: ProviderRequestId }): void => { + detectedWorktreeCancellations.cancel(event, args.providerRequestId) } ) @@ -1767,7 +2547,10 @@ export function registerWorktreeHandlers( ) } - const linkedPaths = repo.symlinkPaths ?? [] + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = getWorktreeSharedLinkPaths(repo) const ignoredLinkedPaths = args.force ? [] : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) @@ -2073,6 +2856,12 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listLineageForHost', + (_event, args: ListDesktopLineageForHostArgs): Promise => + listDesktopLineageForHost(store, runtime, args) + ) + ipcMain.handle( 'worktrees:updateLineage', async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts new file mode 100644 index 00000000000..9ee688cea4b --- /dev/null +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' + +const writeFileAtomically = vi.fn() +const readTallyFile = vi.fn() +const watchStart = vi.fn() +const watchStop = vi.fn() +const watchOptions: { onPrompt: () => void }[] = [] +vi.mock('./codex-accounts/fs-utils', () => ({ + writeFileAtomically: (...args: unknown[]) => writeFileAtomically(...args) +})) +vi.mock('./persistence', () => ({ getCanonicalUserDataPath: () => '/tmp/orca-tcc-notice-test' })) +vi.mock('./macos-tcc-prompt-watch', () => ({ + MacosTccPromptWatch: class { + constructor(options: { onPrompt: () => void }) { + watchOptions.push(options) + } + start(): void { + watchStart() + } + stop(): void { + watchStop() + } + } +})) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + readFileSync: (...args: unknown[]) => readTallyFile(...args) +})) + +const { + TCC_PROMPT_NOTICE_THRESHOLD, + TCC_PROMPT_WATCH_START_FALLBACK_MS, + acknowledgePendingTccPromptNotice, + consumePendingTccPromptNotice, + dismissTccPromptNotice, + handleTccPromptForTests, + initTccPromptNotice, + releasePendingTccPromptNotice, + resetTccPromptNoticeForTests, + stopTccPromptNotice +} = await import('./macos-tcc-prompt-notice') + +beforeEach(() => { + resetTccPromptNoticeForTests() + watchOptions.length = 0 + watchStart.mockClear() + watchStop.mockClear() + writeFileAtomically.mockClear() + readTallyFile.mockReset() + readTallyFile.mockImplementation(() => { + throw new Error('ENOENT') + }) +}) + +describe('tcc prompt notice threshold', () => { + it('stays silent until the third dialog, then fires exactly once', () => { + expect(handleTccPromptForTests()).toBeNull() + expect(handleTccPromptForTests()).toBeNull() + + const payload = handleTccPromptForTests() + expect(payload).toEqual({ + promptCount: TCC_PROMPT_NOTICE_THRESHOLD + }) + + // Why: a recurring prompt loop must not produce a toast per dialog. + expect(handleTccPromptForTests()).toBeNull() + expect(handleTccPromptForTests()).toBeNull() + }) + + it('persists the tally on every prompt so the count survives relaunch', () => { + handleTccPromptForTests() + expect(writeFileAtomically).toHaveBeenCalledTimes(1) + const [, contents] = writeFileAtomically.mock.calls[0] as [string, string] + expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false }) + }) + + it('never fires again once dismissed, even past the threshold', () => { + dismissTccPromptNotice() + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD + 2; i += 1) { + expect(handleTccPromptForTests()).toBeNull() + } + }) + + it('stops tally writes after the one-time notice fires', () => { + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + handleTccPromptForTests() + } + writeFileAtomically.mockClear() + + expect(handleTccPromptForTests()).toBeNull() + expect(writeFileAtomically).not.toHaveBeenCalled() + }) + + it('retains the threshold until the renderer acknowledges its claim', () => { + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + handleTccPromptForTests() + } + writeFileAtomically.mockClear() + + const claim = consumePendingTccPromptNotice(1) + expect(claim).toEqual({ + claimId: 1, + promptCount: TCC_PROMPT_NOTICE_THRESHOLD + }) + expect(consumePendingTccPromptNotice(2)).toBeNull() + expect(writeFileAtomically).not.toHaveBeenCalled() + acknowledgePendingTccPromptNotice(1, claim!.claimId) + expect(consumePendingTccPromptNotice(2)).toBeNull() + expect(writeFileAtomically).toHaveBeenCalledOnce() + const [, contents] = writeFileAtomically.mock.calls.at(-1) as [string, string] + expect(JSON.parse(contents)).toMatchObject({ promptCount: 3, notified: true }) + }) + + it('releases an unacknowledged claim for a replacement renderer', () => { + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + handleTccPromptForTests() + } + + const oldClaim = consumePendingTccPromptNotice(1) + releasePendingTccPromptNotice(1, oldClaim!.claimId + 1) + expect(consumePendingTccPromptNotice(2)).toBeNull() + releasePendingTccPromptNotice(1, oldClaim!.claimId) + const replacementClaim = consumePendingTccPromptNotice(2) + + expect(oldClaim).toEqual({ claimId: 1, promptCount: 3 }) + expect(replacementClaim).toEqual({ claimId: 2, promptCount: 3 }) + acknowledgePendingTccPromptNotice(1, oldClaim!.claimId) + releasePendingTccPromptNotice(2) + expect(consumePendingTccPromptNotice(3)).toEqual({ claimId: 3, promptCount: 3 }) + }) + + it('invalidates an outstanding claim when the user dismisses the notice', () => { + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + handleTccPromptForTests() + } + const claim = consumePendingTccPromptNotice(1) + + dismissTccPromptNotice() + acknowledgePendingTccPromptNotice(1, claim!.claimId) + + expect(consumePendingTccPromptNotice(2)).toBeNull() + const [, contents] = writeFileAtomically.mock.calls.at(-1) as [string, string] + expect(JSON.parse(contents)).toMatchObject({ dismissed: true, notified: true }) + }) + + it('routes a later prompt to the replacement main window', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const oldWindow = createWindowStub() + const newWindow = createWindowStub() + try { + initTccPromptNotice(oldWindow as never) + initTccPromptNotice(newWindow as never) + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + watchOptions[0].onPrompt() + } + expect(oldWindow.webContents.send).not.toHaveBeenCalled() + expect(newWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(watchStop).toHaveBeenCalledTimes(1) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('starts the log reader only after the first window is ready to show', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const mainWindow = createWindowStub() + try { + initTccPromptNotice(mainWindow as never, { deferWatchUntilReadyToShow: true }) + expect(watchStart).not.toHaveBeenCalled() + + const readyToShow = mainWindow.once.mock.calls.find( + ([event]) => event === 'ready-to-show' + )?.[1] + readyToShow?.() + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(watchStart).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('starts immediately when startup deferral is not requested', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + initTccPromptNotice(createWindowStub() as never) + expect(watchStart).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('starts once from the fallback when ready-to-show never arrives', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + vi.useFakeTimers() + const mainWindow = createWindowStub() + try { + initTccPromptNotice(mainWindow as never, { deferWatchUntilReadyToShow: true }) + await vi.advanceTimersByTimeAsync(TCC_PROMPT_WATCH_START_FALLBACK_MS) + await vi.runAllTimersAsync() + + expect(watchStart).toHaveBeenCalledOnce() + const readyToShow = mainWindow.once.mock.calls.find( + ([event]) => event === 'ready-to-show' + )?.[1] + readyToShow?.() + await vi.runAllTimersAsync() + expect(watchStart).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('invalidates deferred starts across repeated init and stop', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const oldWindow = createWindowStub() + const newWindow = createWindowStub() + try { + initTccPromptNotice(oldWindow as never, { deferWatchUntilReadyToShow: true }) + const oldReady = oldWindow.once.mock.calls.find(([event]) => event === 'ready-to-show')?.[1] + initTccPromptNotice(newWindow as never, { deferWatchUntilReadyToShow: true }) + const newReady = newWindow.once.mock.calls.find(([event]) => event === 'ready-to-show')?.[1] + + oldReady?.() + await new Promise((resolve) => { + setImmediate(resolve) + }) + expect(watchStart).not.toHaveBeenCalled() + + stopTccPromptNotice() + newReady?.() + await new Promise((resolve) => { + setImmediate(resolve) + }) + expect(watchStart).not.toHaveBeenCalled() + expect(watchStop).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('does not send through a destroyed main window', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const mainWindow = createWindowStub() + mainWindow.isDestroyed.mockReturnValue(true) + try { + initTccPromptNotice(mainWindow as never) + expect(() => { + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + watchOptions[0].onPrompt() + } + }).not.toThrow() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(watchStop).toHaveBeenCalledTimes(1) + expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 3 }) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('retains the threshold and stops watching when renderer delivery throws', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const mainWindow = createWindowStub() + mainWindow.webContents.send.mockImplementation(() => { + throw new Error('renderer unavailable') + }) + try { + initTccPromptNotice(mainWindow as never) + expect(() => { + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + watchOptions[0].onPrompt() + } + }).not.toThrow() + + expect(watchStop).toHaveBeenCalledOnce() + expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 3 }) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('does not respawn the watcher for a persisted pending threshold', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue( + JSON.stringify({ promptCount: 3, notified: false, dismissed: false }) + ) + try { + const mainWindow = createWindowStub() + initTccPromptNotice(mainWindow as never) + + expect(watchStart).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('macosTccPrompts:threshold', { + promptCount: 3 + }) + expect(mainWindow.once).not.toHaveBeenCalled() + expect(consumePendingTccPromptNotice(1)).toEqual({ claimId: 1, promptCount: 3 }) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + + it('does not let a late old-window close clear the replacement target', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + const oldWindow = createWindowStub() + const newWindow = createWindowStub() + try { + initTccPromptNotice(oldWindow as never) + const oldClosed = oldWindow.once.mock.calls.find(([event]) => event === 'closed')?.[1] + initTccPromptNotice(newWindow as never) + oldClosed?.() + for (let i = 0; i < TCC_PROMPT_NOTICE_THRESHOLD; i += 1) { + watchOptions[0].onPrompt() + } + + expect(oldWindow.webContents.send).not.toHaveBeenCalled() + expect(newWindow.webContents.send).toHaveBeenCalledTimes(1) + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) +}) + +function createWindowStub() { + return { + isDestroyed: vi.fn(() => false), + once: vi.fn(), + webContents: { + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + } +} diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts new file mode 100644 index 00000000000..30b96f36c39 --- /dev/null +++ b/src/main/macos-tcc-prompt-notice.ts @@ -0,0 +1,250 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { writeFileAtomically } from './codex-accounts/fs-utils' +import { getCanonicalUserDataPath } from './persistence' +import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' + +/** + * Surfaces Full Disk Access guidance only to users macOS is actually prompting + * (#9756), instead of nudging every Mac user. Counts Orca-attributed TCC + * dialogs across launches and tells the renderer once the third one lands. + */ + +/** Why: one dialog is normal and two is bad luck; three means it is recurring for this user. */ +export const TCC_PROMPT_NOTICE_THRESHOLD = 3 + +/** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */ +export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000 + +export const TCC_PROMPT_NOTICE_CHANNEL = 'macosTccPrompts:threshold' + +export type TccPromptNoticePayload = { + promptCount: number +} + +export type TccPromptNoticeClaim = TccPromptNoticePayload & { + claimId: number +} + +type TccPromptTally = { + promptCount: number + notified: boolean + dismissed: boolean +} + +const EMPTY_TALLY: TccPromptTally = { promptCount: 0, notified: false, dismissed: false } + +let tally: TccPromptTally = { ...EMPTY_TALLY } +let mainWindowRef: BrowserWindow | null = null +let watch: MacosTccPromptWatch | null = null +let nextClaimId = 0 +let pendingClaim: { claimId: number; ownerToken: number } | null = null +let deferredWatchStartTimer: ReturnType | null = null +let deferredWatchStartGeneration = 0 + +function tallyPath(): string { + return join(getCanonicalUserDataPath(), 'macos-tcc-prompt-tally.json') +} + +function loadTally(): TccPromptTally { + try { + const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial + return { + promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, + notified: parsed.notified === true, + dismissed: parsed.dismissed === true + } + } catch { + return { ...EMPTY_TALLY } + } +} + +function saveTally(): void { + try { + writeFileAtomically(tallyPath(), `${JSON.stringify(tally, null, 2)}\n`) + } catch { + // Best-effort: losing the count only means the notice arrives a launch later. + } +} + +export function handleTccPromptForTests(): TccPromptNoticePayload | null { + return recordPrompt() +} + +function recordPrompt(): TccPromptNoticePayload | null { + if (tally.dismissed || tally.notified || tally.promptCount >= TCC_PROMPT_NOTICE_THRESHOLD) { + return null + } + tally = { ...tally, promptCount: tally.promptCount + 1 } + saveTally() + if (tally.promptCount < TCC_PROMPT_NOTICE_THRESHOLD) { + return null + } + return { promptCount: tally.promptCount } +} + +export function consumePendingTccPromptNotice(ownerToken: number): TccPromptNoticeClaim | null { + if ( + pendingClaim || + tally.dismissed || + tally.notified || + tally.promptCount < TCC_PROMPT_NOTICE_THRESHOLD + ) { + return null + } + const claimId = ++nextClaimId + pendingClaim = { claimId, ownerToken } + return { claimId, promptCount: tally.promptCount } +} + +export function acknowledgePendingTccPromptNotice(ownerToken: number, claimId: number): void { + if ( + pendingClaim?.ownerToken !== ownerToken || + pendingClaim.claimId !== claimId || + tally.dismissed || + tally.notified + ) { + return + } + pendingClaim = null + tally = { ...tally, notified: true } + saveTally() +} + +export function releasePendingTccPromptNotice(ownerToken: number, claimId?: number): void { + if ( + pendingClaim?.ownerToken === ownerToken && + (claimId === undefined || pendingClaim.claimId === claimId) + ) { + pendingClaim = null + } +} + +/** Permanently stops the notice for this user; the watcher shuts down with it. */ +export function dismissTccPromptNotice(): void { + pendingClaim = null + tally = { ...tally, dismissed: true, notified: true } + saveTally() + stopTccPromptNotice() +} + +function trackMainWindow(mainWindow: BrowserWindow): void { + mainWindowRef = mainWindow + mainWindow.once('closed', () => { + if (mainWindowRef === mainWindow) { + mainWindowRef = null + } + }) +} + +function sendTccPromptNotice(mainWindow: BrowserWindow, payload: TccPromptNoticePayload): void { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + return + } + try { + mainWindow.webContents.send(TCC_PROMPT_NOTICE_CHANNEL, payload) + } catch { + // Why: the durable renderer pull recovers a send lost during renderer teardown. + } +} + +function cancelDeferredWatchStart(): void { + deferredWatchStartGeneration += 1 + if (deferredWatchStartTimer) { + clearTimeout(deferredWatchStartTimer) + deferredWatchStartTimer = null + } +} + +function startWatchAfterFirstVisibleProgress( + mainWindow: BrowserWindow, + targetWatch: MacosTccPromptWatch, + deferUntilReadyToShow: boolean +): void { + cancelDeferredWatchStart() + if (!deferUntilReadyToShow) { + targetWatch.start() + return + } + const generation = deferredWatchStartGeneration + const startDeferredWatch = (): void => { + if (deferredWatchStartGeneration !== generation) { + return + } + cancelDeferredWatchStart() + const startGeneration = deferredWatchStartGeneration + setImmediate(() => { + if (deferredWatchStartGeneration === startGeneration && watch === targetWatch) { + targetWatch.start() + } + }) + } + mainWindow.once('ready-to-show', startDeferredWatch) + deferredWatchStartTimer = setTimeout(startDeferredWatch, TCC_PROMPT_WATCH_START_FALLBACK_MS) + deferredWatchStartTimer.unref?.() +} + +export function initTccPromptNotice( + mainWindow: BrowserWindow, + options?: { deferWatchUntilReadyToShow?: boolean } +): void { + if (process.platform !== 'darwin') { + return + } + if (watch) { + trackMainWindow(mainWindow) + startWatchAfterFirstVisibleProgress( + mainWindow, + watch, + options?.deferWatchUntilReadyToShow === true + ) + return + } + tally = loadTally() + if (tally.dismissed || tally.notified) { + return + } + if (tally.promptCount >= TCC_PROMPT_NOTICE_THRESHOLD) { + sendTccPromptNotice(mainWindow, { + promptCount: tally.promptCount + }) + return + } + trackMainWindow(mainWindow) + watch = new MacosTccPromptWatch({ + onPrompt: () => { + const payload = recordPrompt() + if (!payload) { + return + } + const target = mainWindowRef + if (target) { + sendTccPromptNotice(target, payload) + } + // Why: pending state is renderer-acknowledged, so the log child can stop at threshold. + stopTccPromptNotice() + } + }) + startWatchAfterFirstVisibleProgress( + mainWindow, + watch, + options?.deferWatchUntilReadyToShow === true + ) +} + +export function stopTccPromptNotice(): void { + cancelDeferredWatchStart() + watch?.stop() + watch = null + mainWindowRef = null +} + +export function resetTccPromptNoticeForTests(): void { + cancelDeferredWatchStart() + tally = { ...EMPTY_TALLY } + watch = null + mainWindowRef = null + nextClaimId = 0 + pendingClaim = null +} diff --git a/src/main/macos-tcc-prompt-watch.test.ts b/src/main/macos-tcc-prompt-watch.test.ts new file mode 100644 index 00000000000..a0e9d9370a6 --- /dev/null +++ b/src/main/macos-tcc-prompt-watch.test.ts @@ -0,0 +1,226 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MacosTccPromptWatch, + type LogStreamChild, + isOrcaAttributedPrompt, + parseTccPromptEvent +} from './macos-tcc-prompt-watch' + +// Captured verbatim from `log stream --predicate 'subsystem == "com.apple.TCC"'` +// on macOS 26.5 while a real consent dialog was displayed and denied. +const REAL_PROMPT_LINE = + '2026-07-27 15:35:26.136 Df tccd[79149:c81551c] [com.apple.TCC:access] AUTHREQ_PROMPTING: msgID=80871.81, service=kTCCServiceSystemPolicyDocumentsFolder, subject=Sub:{com.orca.tccprobe.shapecapture}Resp:{TCCDProcess: identifier=com.orca.tccprobe.shapecapture, pid=74171, auid=501, euid=501, binary_path=/private/tmp/tccprobe/TccProbe.app/Contents/MacOS/TccProbe},' + +// Same shape, but the #9756 case: an agent CLI accesses, Orca is held responsible. +const ORCA_APPDATA_LINE = + '2026-07-27 15:40:02.001 Df tccd[79149:c81551c] [com.apple.TCC:access] AUTHREQ_PROMPTING: msgID=80871.99, service=kTCCServiceSystemPolicyAppData, subject=Sub:{node-5555494487fbc7467d473fd8b0a397018cbf954b}Resp:{TCCDProcess: identifier=com.stablyai.orca, pid=47548, auid=501, euid=501, binary_path=/opt/homebrew/Cellar/node/26.5.0/bin/node},' + +// Preflight checks dominate the TCC subsystem and must never count as a dialog. +const PREFLIGHT_LINE = + '2026-07-27 15:23:26.420 Df tccd[79149:c7d3abb] [com.apple.TCC:access] AUTHREQ_CTX: msgID=36906.2, function=, service=kTCCServiceMicrophone, preflight=yes, query=1, client_dict=(null), daemon_dict=' + +describe('parseTccPromptEvent', () => { + it('parses a real captured AUTHREQ_PROMPTING line', () => { + expect(parseTccPromptEvent(REAL_PROMPT_LINE)).toEqual({ + service: 'kTCCServiceSystemPolicyDocumentsFolder', + accessingIdentifier: 'com.orca.tccprobe.shapecapture', + responsibleIdentifier: 'com.orca.tccprobe.shapecapture', + binaryPath: '/private/tmp/tccprobe/TccProbe.app/Contents/MacOS/TccProbe' + }) + }) + + it('separates the accessing binary from the responsible app', () => { + const event = parseTccPromptEvent(ORCA_APPDATA_LINE) + // The whole point of #9756: the dialog says Orca, but node did the access. + expect(event?.responsibleIdentifier).toBe('com.stablyai.orca') + expect(event?.accessingIdentifier).toBe('node-5555494487fbc7467d473fd8b0a397018cbf954b') + expect(event?.binaryPath).toBe('/opt/homebrew/Cellar/node/26.5.0/bin/node') + }) + + it('ignores preflight checks, the log header, and malformed lines', () => { + expect(parseTccPromptEvent(PREFLIGHT_LINE)).toBeNull() + expect(parseTccPromptEvent('Filtering the log data using "subsystem == ..."')).toBeNull() + expect(parseTccPromptEvent('')).toBeNull() + // Has the marker but no parseable identities — must not yield a partial event. + expect(parseTccPromptEvent('AUTHREQ_PROMPTING: msgID=1.2,')).toBeNull() + }) +}) + +describe('isOrcaAttributedPrompt', () => { + it('accepts the app and detached terminal helper across Orca build identities', () => { + for (const id of [ + 'com.stablyai.orca', + 'com.stablyai.orca.helper', + 'com.stablyai.orca.dev', + 'com.stablyai.orca.dev.helper', + 'com.stablyai.orca.local', + 'com.stablyai.orca.local.helper' + ]) { + expect( + isOrcaAttributedPrompt({ + service: 'kTCCServiceSystemPolicyAppData', + accessingIdentifier: 'find', + responsibleIdentifier: id + }) + ).toBe(true) + } + }) + + it('rejects dialogs another app is responsible for', () => { + expect( + isOrcaAttributedPrompt({ + service: 'kTCCServiceSystemPolicyAppData', + accessingIdentifier: 'find', + responsibleIdentifier: 'com.apple.Terminal' + }) + ).toBe(false) + }) + + it('rejects unrelated services even when Orca is responsible', () => { + expect( + isOrcaAttributedPrompt({ + service: 'kTCCServiceMicrophone', + accessingIdentifier: 'orca', + responsibleIdentifier: 'com.stablyai.orca' + }) + ).toBe(false) + }) +}) + +function createFakeLogStream(): { + child: LogStreamChild + stdout: PassThrough + killed: string[] +} { + const stdout = new PassThrough() + const killed: string[] = [] + const child = Object.assign(new EventEmitter(), { + stdout, + stderr: new PassThrough(), + kill: (signal?: string) => { + killed.push(signal ?? 'SIGTERM') + return true + } + }) as unknown as LogStreamChild + return { child, stdout, killed } +} + +// Why: the watcher is darwin-gated, so CI (Linux) would silently no-op every +// assertion below unless the platform is pinned. The gate itself is covered by +// the non-darwin case at the end of this block. +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value }) +} + +describe('MacosTccPromptWatch', () => { + beforeEach(() => { + setPlatform('darwin') + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('never spawns a log reader off macOS', () => { + setPlatform('linux') + const spawnLogStream = vi.fn() + const watch = new MacosTccPromptWatch({ onPrompt: vi.fn(), spawnLogStream }) + watch.start() + expect(spawnLogStream).not.toHaveBeenCalled() + }) + + it('reports only Orca-attributed dialogs from a live stream', async () => { + const { child, stdout } = createFakeLogStream() + const onPrompt = vi.fn() + const watch = new MacosTccPromptWatch({ onPrompt, spawnLogStream: () => child }) + watch.start() + + stdout.write('Filtering the log data using "subsystem == ..."\n') + stdout.write(`${PREFLIGHT_LINE}\n`) + stdout.write(`${REAL_PROMPT_LINE}\n`) // another app is responsible + stdout.write(`${ORCA_APPDATA_LINE}\n`) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(onPrompt).toHaveBeenCalledTimes(1) + expect(onPrompt.mock.calls[0][0]).toMatchObject({ + service: 'kTCCServiceSystemPolicyAppData', + responsibleIdentifier: 'com.stablyai.orca' + }) + watch.stop() + }) + + it('kills the child on stop so it cannot outlive app quit', () => { + const { child, killed } = createFakeLogStream() + const watch = new MacosTccPromptWatch({ onPrompt: vi.fn(), spawnLogStream: () => child }) + watch.start() + watch.stop() + expect(killed).toEqual(['SIGTERM']) + }) + + it('restarts once after an unexpected termination without creating a retry loop', async () => { + const first = createFakeLogStream() + const second = createFakeLogStream() + const spawnLogStream = vi + .fn<() => LogStreamChild>() + .mockReturnValueOnce(first.child) + .mockReturnValueOnce(second.child) + const watch = new MacosTccPromptWatch({ + onPrompt: vi.fn(), + spawnLogStream, + restartDelayMs: 0 + }) + watch.start() + + first.child.emit('error', new Error('logd restarted')) + first.child.emit('exit', 1) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(spawnLogStream).toHaveBeenCalledTimes(2) + + second.child.emit('exit', 1) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(spawnLogStream).toHaveBeenCalledTimes(2) + watch.stop() + }) + + it('cancels a pending restart when stopped', async () => { + const first = createFakeLogStream() + const spawnLogStream = vi.fn(() => first.child) + const watch = new MacosTccPromptWatch({ + onPrompt: vi.fn(), + spawnLogStream, + restartDelayMs: 0 + }) + watch.start() + first.child.emit('exit', 1) + watch.stop() + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(spawnLogStream).toHaveBeenCalledOnce() + }) + + it('does not restart after stop, and survives a spawn failure', () => { + const spawnLogStream = vi.fn(() => { + throw new Error('log binary unavailable') + }) + const watch = new MacosTccPromptWatch({ onPrompt: vi.fn(), spawnLogStream }) + expect(() => watch.start()).not.toThrow() + + const { child } = createFakeLogStream() + const afterStop = new MacosTccPromptWatch({ + onPrompt: vi.fn(), + spawnLogStream: () => child + }) + afterStop.stop() + afterStop.start() + // Why: quit ordering can call stop() before start(); it must not leave a live child behind. + expect(afterStop['child']).toBeNull() + }) +}) diff --git a/src/main/macos-tcc-prompt-watch.ts b/src/main/macos-tcc-prompt-watch.ts new file mode 100644 index 00000000000..acff095a82b --- /dev/null +++ b/src/main/macos-tcc-prompt-watch.ts @@ -0,0 +1,172 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process' +import { createInterface, type Interface } from 'node:readline' +import type { Readable } from 'node:stream' + +/** Why: stdin is 'ignore', so this is narrower than ChildProcessWithoutNullStreams. */ +export type LogStreamChild = ChildProcessByStdio + +/** + * Counts the macOS TCC consent dialogs that name Orca as the responsible + * process (#9756). Terminal children — agent CLIs and anything else the user + * runs — perform the access, but TCC walks the responsibility chain back to + * Orca and puts Orca's name on the dialog, so users read it as Orca snooping. + * + * tccd emits one `AUTHREQ_PROMPTING` line per dialog it actually displays, + * carrying the service and both identities, so this never has to correlate + * across lines or guess whether a dialog was shown. Routine preflight checks + * (the overwhelming majority of TCC log traffic) do not emit it. + */ + +/** Why: terminals run from the detached helper, which TCC can hold responsible independently. */ +const ORCA_RESPONSIBLE_IDENTIFIERS = new Set([ + 'com.stablyai.orca', + 'com.stablyai.orca.helper', + 'com.stablyai.orca.dev', + 'com.stablyai.orca.dev.helper', + 'com.stablyai.orca.local', + 'com.stablyai.orca.local.helper' +]) + +/** Why: the prompt classes #9756 is about — other-apps' data plus the protected home folders agents sweep. */ +const WATCHED_SERVICES = new Set([ + 'kTCCServiceSystemPolicyAppData', + 'kTCCServiceSystemPolicyAllFiles', + 'kTCCServiceSystemPolicyDocumentsFolder', + 'kTCCServiceSystemPolicyDesktopFolder', + 'kTCCServiceSystemPolicyDownloadsFolder' +]) + +const LOG_PREDICATE = 'subsystem == "com.apple.TCC" AND eventMessage CONTAINS "AUTHREQ_PROMPTING"' + +export type TccPromptEvent = { + /** TCC service the dialog was raised for, e.g. `kTCCServiceSystemPolicyAppData`. */ + service: string + /** Bundle id or hashed identity of the process performing the access (often an agent CLI). */ + accessingIdentifier: string + /** Bundle id macOS holds responsible, and therefore names in the dialog. */ + responsibleIdentifier: string + /** Absolute path of the accessing binary, when tccd reports one. */ + binaryPath?: string +} + +/** + * Parses one `AUTHREQ_PROMPTING` log line. Returns null for anything else, so + * callers can feed the raw stream (which includes a header line) straight in. + */ +export function parseTccPromptEvent(line: string): TccPromptEvent | null { + if (!line.includes('AUTHREQ_PROMPTING')) { + return null + } + const service = /\bservice=(kTCCService\w+)/.exec(line)?.[1] + const accessingIdentifier = /\bSub:\{([^}]*)\}/.exec(line)?.[1] + const responsibleIdentifier = /\bResp:\{TCCDProcess:\s*identifier=([^,}]+)/.exec(line)?.[1] + if (!service || !accessingIdentifier || !responsibleIdentifier) { + return null + } + const binaryPath = /\bbinary_path=([^,}]+)/.exec(line)?.[1] + return { + service, + accessingIdentifier: accessingIdentifier.trim(), + responsibleIdentifier: responsibleIdentifier.trim(), + ...(binaryPath ? { binaryPath: binaryPath.trim() } : {}) + } +} + +/** True when this dialog is one macOS raised in Orca's name for a watched file-access service. */ +export function isOrcaAttributedPrompt(event: TccPromptEvent): boolean { + return ( + ORCA_RESPONSIBLE_IDENTIFIERS.has(event.responsibleIdentifier) && + WATCHED_SERVICES.has(event.service) + ) +} + +export type TccPromptWatchOptions = { + onPrompt: (event: TccPromptEvent) => void + /** Injected in tests; defaults to spawning `log stream`. */ + spawnLogStream?: () => LogStreamChild + /** Injected in tests; production waits before its single recovery attempt. */ + restartDelayMs?: number +} + +function spawnDefaultLogStream(): LogStreamChild { + // Why: the predicate is evaluated inside the logging system, so this delivers + // ~1 line per real dialog instead of the ~30/s the TCC subsystem emits raw. + return spawn( + '/usr/bin/log', + ['stream', '--predicate', LOG_PREDICATE, '--info', '--style', 'compact'], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) +} + +/** + * Watches for Orca-attributed TCC dialogs. macOS-only; `start()` is a no-op + * elsewhere so callers don't need their own platform guard. + */ +export class MacosTccPromptWatch { + private child: LogStreamChild | null = null + private reader: Interface | null = null + private restartTimer: ReturnType | null = null + private restartAttempted = false + private stopped = false + + constructor(private readonly options: TccPromptWatchOptions) {} + + start(): void { + if (process.platform !== 'darwin' || this.child || this.stopped) { + return + } + const spawnLogStream = this.options.spawnLogStream ?? spawnDefaultLogStream + let child: LogStreamChild + try { + child = spawnLogStream() + } catch { + // Why: diagnostics only — a missing or restricted `log` binary must never + // break startup, and there is nothing actionable to tell the user. + return + } + this.child = child + child.on('error', () => this.handleUnexpectedTermination(child)) + child.on('exit', () => this.handleUnexpectedTermination(child)) + this.reader = createInterface({ input: child.stdout }) + this.reader.on('line', (line) => this.handleLine(line)) + } + + private handleUnexpectedTermination(child: LogStreamChild): void { + if (this.child !== child) { + return + } + this.reader?.close() + this.reader = null + this.child = null + if (this.stopped || this.restartAttempted) { + return + } + this.restartAttempted = true + // Why: recover one transient logd failure without respawning forever when logging is unavailable. + this.restartTimer = setTimeout(() => { + this.restartTimer = null + this.start() + }, this.options.restartDelayMs ?? 1_000) + } + + private handleLine(line: string): void { + const event = parseTccPromptEvent(line) + if (!event || !isOrcaAttributedPrompt(event)) { + return + } + this.options.onPrompt(event) + } + + stop(): void { + this.stopped = true + if (this.restartTimer) { + clearTimeout(this.restartTimer) + this.restartTimer = null + } + this.reader?.close() + this.reader = null + // Why: log stream ignores a closed stdout, so the child needs an explicit kill or it outlives quit. + this.child?.kill('SIGTERM') + this.child = null + } +} diff --git a/src/main/opencode/hook-plugin-child-attention.test.ts b/src/main/opencode/hook-plugin-child-attention.test.ts new file mode 100644 index 00000000000..058daf2ea1b --- /dev/null +++ b/src/main/opencode/hook-plugin-child-attention.test.ts @@ -0,0 +1,610 @@ +/** + * Executes the generated OpenCode plugin artifact to verify that descendant + * blockers roll up to their root pane without giving child lifecycle or + * message events authority over that pane. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type SessionFixture = { id: string; parentID?: string } +type PluginEvent = { type: string; properties?: Record } +type PluginEventHandler = (input: { event: PluginEvent }) => Promise +type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } +type PluginFactory = (ctx: unknown) => Promise +type SessionList = ( + parameters?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal } +) => Promise<{ data: SessionFixture[] }> +type RecordedPost = { + hook_event_name: string + id?: string + sessionID?: string +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +describe('OpenCode plugin child attention', () => { + let tempDir: string + let posts: RecordedPost[] + let savedEnv: Record + let savedFetch: typeof globalThis.fetch + let pluginFactory: PluginFactory | undefined + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-child-attention-')) + posts = [] + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + pluginFactory = undefined + savedFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { payload: RecordedPost } + posts.push(body.payload) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function createHooks( + sessions: SessionFixture[], + list: SessionList = async () => ({ + data: sessions + }) + ): Promise { + if (!pluginFactory) { + const pluginPath = join(tempDir, 'orca-opencode-status.mjs') + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + const module = (await import(pathToFileURL(pluginPath).href)) as { + OrcaOpenCodeStatusPlugin: PluginFactory + } + pluginFactory = module.OrcaOpenCodeStatusPlugin + } + return pluginFactory({ + client: { + session: { + list + } + } + }) + } + + function status(type: 'busy' | 'idle', sessionID: string): PluginEvent { + return { + type: 'session.status', + properties: { sessionID, status: { type } } + } + } + + function attention( + type: 'permission.asked' | 'question.asked', + id: string, + sessionID: string, + tool?: { messageID: string; callID: string } + ): PluginEvent { + return { + type, + properties: { id, sessionID, ...(tool ? { tool } : {}) } + } + } + + function resolution( + type: 'permission.replied' | 'question.replied' | 'question.rejected', + requestID: string, + sessionID: string + ): PluginEvent { + return { + type, + properties: { requestID, sessionID } + } + } + + function completedQuestion( + sessionID: string, + messageID: string, + callID: string, + status: 'completed' | 'error' = 'completed' + ): PluginEvent { + return { + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: 'tool', + tool: 'question', + messageID, + callID, + state: { status } + } + } + } + } + + function names(): string[] { + return posts.map((post) => post.hook_event_name) + } + + it.each([ + ['permission.asked', 'PermissionRequest'], + ['question.asked', 'AskUserQuestion'] + ] as const)('attributes nested child %s to the root pane', async (type, expectedName) => { + const hooks = await createHooks([ + { id: 'root' }, + { id: 'child', parentID: 'root' }, + { id: 'grandchild', parentID: 'child' } + ]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: attention(type, 'request-1', 'grandchild') }) + + expect(posts.at(-1)).toMatchObject({ + hook_event_name: expectedName, + id: 'request-1', + sessionID: 'root' + }) + }) + + it('clears only the matching child reply or rejection and preserves root Busy', async () => { + const hooks = await createHooks([ + { id: 'root' }, + { id: 'child-a', parentID: 'root' }, + { id: 'child-b', parentID: 'root' } + ]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: attention('question.asked', 'question-a', 'child-a') }) + const waitingPosts = posts.length + await hooks.event({ event: status('busy', 'root') }) + expect(posts).toHaveLength(waitingPosts) + + await hooks.event({ event: attention('permission.asked', 'permission-b', 'child-b') }) + await hooks.event({ + event: resolution('permission.replied', 'permission-b', 'child-a') + }) + expect(posts.at(-1)?.hook_event_name).toBe('PermissionRequest') + + await hooks.event({ + event: resolution('permission.replied', 'permission-b', 'child-b') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'question-a', + sessionID: 'root' + }) + + await hooks.event({ + event: resolution('question.rejected', 'question-a', 'child-a') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionBusy', + sessionID: 'root' + }) + + await hooks.event({ event: status('idle', 'root') }) + expect(posts.at(-1)?.hook_event_name).toBe('SessionIdle') + }) + + it.each(['completed', 'error'] as const)( + 'clears only the matching child question when its tool is %s', + async (completionStatus) => { + const hooks = await createHooks([ + { id: 'root' }, + { id: 'child-a', parentID: 'root' }, + { id: 'child-b', parentID: 'root' } + ]) + const toolA = { messageID: 'message-a', callID: 'call-a' } + const toolB = { messageID: 'message-b', callID: 'call-b' } + + await hooks.event({ + event: attention('question.asked', 'question-a', 'child-a', toolA) + }) + await hooks.event({ + event: attention('question.asked', 'question-b', 'child-b', toolB) + }) + const waitingPosts = posts.length + await hooks.event({ + event: completedQuestion('child-a', toolB.messageID, toolB.callID, completionStatus) + }) + await hooks.event({ + event: completedQuestion('child-b', toolB.messageID, 'wrong-call', completionStatus) + }) + expect(posts).toHaveLength(waitingPosts) + + await hooks.event({ + event: completedQuestion('child-b', toolB.messageID, toolB.callID, completionStatus) + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'question-a', + sessionID: 'root' + }) + + await hooks.event({ + event: completedQuestion('child-a', toolA.messageID, toolA.callID, completionStatus) + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionIdle', + sessionID: 'root' + }) + } + ) + + it('uses child Idle only to clean that child blocker, then restores root Busy', async () => { + const hooks = await createHooks([{ id: 'root' }, { id: 'child', parentID: 'root' }]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: attention('question.asked', 'question-child', 'child') }) + await hooks.event({ event: status('idle', 'child') }) + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + }) + + it('lets only matching unknown Idle retire attention across cyclic ancestry', async () => { + const list = vi.fn(async () => ({ + data: [ + { id: 'child-a', parentID: 'child-b' }, + { id: 'child-b', parentID: 'child-a' } + ] + })) + const hooks = await createHooks([], list) + + await hooks.event({ + event: attention('question.asked', 'cyclic-question', 'child-a') + }) + await hooks.event({ event: status('idle', 'child-b') }) + expect(names()).toEqual(['AskUserQuestion']) + + await hooks.event({ event: status('idle', 'child-a') }) + + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + expect(posts[0]).toMatchObject({ + id: 'cyclic-question', + sessionID: 'child-a' + }) + expect(posts.at(-1)?.sessionID).toBe('child-a') + expect(list).toHaveBeenCalledTimes(6) + }) + + it('matches a child reply after its ancestry lookup fails', async () => { + let lookupFails = false + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: [{ id: 'root' }] } + }) + const hooks = await createHooks([], list) + + await hooks.event({ event: status('busy', 'root') }) + lookupFails = true + await hooks.event({ + event: attention('permission.asked', 'permission-child', 'child') + }) + await hooks.event({ + event: resolution('permission.replied', 'permission-child', 'child') + }) + + expect(names()).toEqual(['SessionBusy', 'PermissionRequest', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + }) + + it('clears timed-out child attention without repeating its ancestry lookup', async () => { + vi.useFakeTimers() + let lookupHangs = false + const list = vi.fn( + async (options?: { signal?: AbortSignal }): Promise<{ data: SessionFixture[] }> => { + if (!lookupHangs) { + return { data: [{ id: 'root' }] } + } + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + } + ) + const hooks = await createHooks([], list) + await hooks.event({ event: status('busy', 'root') }) + lookupHangs = true + + const asked = hooks.event({ + event: attention('question.asked', 'question-child', 'child') + }) + await vi.advanceTimersByTimeAsync(2_000) + await asked + const lookupCountAfterAsk = list.mock.calls.length + const replied = hooks.event({ + event: resolution('question.replied', 'question-child', 'child') + }) + await replied + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + expect(list).toHaveBeenCalledTimes(lookupCountAfterAsk) + }) + + it('clears timed-out child attention on Idle without making unknown Idle authoritative', async () => { + vi.useFakeTimers() + let lookupHangs = false + const list = vi.fn( + async (options?: { signal?: AbortSignal }): Promise<{ data: SessionFixture[] }> => { + if (!lookupHangs) { + return { data: [{ id: 'root' }] } + } + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + } + ) + const hooks = await createHooks([], list) + await hooks.event({ event: status('busy', 'root') }) + lookupHangs = true + + const asked = hooks.event({ + event: attention('question.asked', 'question-child', 'child') + }) + await vi.advanceTimersByTimeAsync(2_000) + await asked + const idle = hooks.event({ event: status('idle', 'child') }) + await vi.advanceTimersByTimeAsync(2_000) + await idle + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + }) + + it('delivers same-id child blockers across factories when the older owner resolves first', async () => { + const first = await createHooks([{ id: 'root-a' }, { id: 'child-a', parentID: 'root-a' }]) + const second = await createHooks([{ id: 'root-b' }, { id: 'child-b', parentID: 'root-b' }]) + + await first.event({ event: status('busy', 'root-a') }) + await second.event({ event: status('busy', 'root-b') }) + await first.event({ event: attention('question.asked', 'same-id', 'child-a') }) + await second.event({ event: attention('question.asked', 'same-id', 'child-b') }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'same-id', + sessionID: 'root-b' + }) + const postsAfterSecondBlocker = posts.length + + await first.event({ + event: resolution('question.replied', 'same-id', 'child-a') + }) + expect(posts).toHaveLength(postsAfterSecondBlocker) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'same-id', + sessionID: 'root-b' + }) + + await second.event({ + event: resolution('question.replied', 'same-id', 'child-b') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionBusy', + sessionID: 'root-b' + }) + }) + + it('keeps same-id blockers from separate sessions in one factory independent', async () => { + const hooks = await createHooks([ + { id: 'root-a' }, + { id: 'child-a', parentID: 'root-a' }, + { id: 'root-b' }, + { id: 'child-b', parentID: 'root-b' } + ]) + + await hooks.event({ event: status('busy', 'root-a') }) + await hooks.event({ event: status('busy', 'root-b') }) + await hooks.event({ event: attention('question.asked', 'same-id', 'child-a') }) + await hooks.event({ event: attention('question.asked', 'same-id', 'child-b') }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + sessionID: 'root-b' + }) + + await hooks.event({ + event: resolution('question.replied', 'same-id', 'child-a') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + sessionID: 'root-b' + }) + }) + + it('keeps the oldest blocker waiting when 128 newer blockers resolve', async () => { + const hooks = await createHooks([]) + + // Why: live blocker ownership cannot be LRU-evicted; 129 crosses the prior 128-entry cap. + for (let index = 1; index <= 129; index += 1) { + await hooks.event({ + event: attention('question.asked', `question-${String(index)}`, `session-${String(index)}`) + }) + } + for (let index = 2; index <= 129; index += 1) { + await hooks.event({ + event: resolution( + 'question.replied', + `question-${String(index)}`, + `session-${String(index)}` + ) + }) + } + + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'question-1', + sessionID: 'session-1' + }) + }) + + it('cleans only the disposed factory blocker', async () => { + const first = await createHooks([{ id: 'root-a' }, { id: 'child-a', parentID: 'root-a' }]) + const second = await createHooks([{ id: 'root-b' }, { id: 'child-b', parentID: 'root-b' }]) + + await first.event({ event: status('busy', 'root-a') }) + await second.event({ event: status('busy', 'root-b') }) + await first.event({ event: attention('question.asked', 'first-id', 'child-a') }) + await second.event({ event: attention('question.asked', 'second-id', 'child-b') }) + await second.dispose?.() + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'first-id', + sessionID: 'root-a' + }) + + await first.event({ + event: resolution('question.replied', 'first-id', 'child-a') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionBusy', + sessionID: 'root-a' + }) + }) + + it('keeps child lifecycle, deltas, and text previews from overriding root status', async () => { + const hooks = await createHooks([{ id: 'root' }, { id: 'child', parentID: 'root' }]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: status('busy', 'child') }) + await hooks.event({ event: status('idle', 'child') }) + await hooks.event({ + event: { + type: 'message.part.delta', + properties: { sessionID: 'child', field: 'text', delta: 'child work' } + } + }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'child', + info: { id: 'child-message', role: 'assistant' } + } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'child', + part: { + type: 'text', + text: 'private child preview', + messageID: 'child-message' + } + } + } + }) + + expect(names()).toEqual(['SessionBusy']) + await hooks.event({ event: status('idle', 'root') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('does not let a delayed child Idle invalidate an in-flight parent preview', async () => { + let releaseRootLookup: (() => void) | undefined + const rootLookup = new Promise((resolve) => { + releaseRootLookup = resolve + }) + let notifyRootLookup: (() => void) | undefined + const rootLookupStarted = new Promise((resolve) => { + notifyRootLookup = resolve + }) + let releaseChildLookup: (() => void) | undefined + const childLookup = new Promise((resolve) => { + releaseChildLookup = resolve + }) + let notifyChildLookup: (() => void) | undefined + const childLookupStarted = new Promise((resolve) => { + notifyChildLookup = resolve + }) + let calls = 0 + const sessions = [{ id: 'root' }, { id: 'child', parentID: 'root' }] + const list = vi.fn(async () => { + calls += 1 + if (calls === 1) { + notifyRootLookup?.() + await rootLookup + } else if (calls === 2) { + notifyChildLookup?.() + await childLookup + } + return { data: sessions } + }) + const hooks = await createHooks([], list) + + const seedRole = hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root', + info: { id: 'parent-message', role: 'assistant' } + } + } + }) + await rootLookupStarted + const preview = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'parent preview', messageID: 'parent-message' } + } + } + }) + const childIdle = hooks.event({ event: status('idle', 'child') }) + + try { + releaseRootLookup?.() + await childLookupStarted + await vi.waitFor(() => expect(names()).toEqual(['MessagePart'])) + } finally { + releaseChildLookup?.() + } + await Promise.all([seedRole, preview, childIdle]) + + // Why: child completion may clean its blockers, but it cannot cancel or + // replace a parent preview that was already on its way to the pane. + expect(names()).toEqual(['MessagePart']) + }) +}) diff --git a/src/main/opencode/hook-plugin-fail-open-ownership.test.ts b/src/main/opencode/hook-plugin-fail-open-ownership.test.ts new file mode 100644 index 00000000000..9f48059604f --- /dev/null +++ b/src/main/opencode/hook-plugin-fail-open-ownership.test.ts @@ -0,0 +1,439 @@ +/** + * Executes the generated OpenCode plugin source because fail-open ownership + * lives inside OpenCode's process, not in Orca's TypeScript runtime. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type SessionFixture = { id: string; parentID?: string } +type PluginEvent = { type: string; properties?: Record } +type PluginEventHandler = (input: { event: PluginEvent }) => Promise +type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } +type RecordedPost = { + hook_event_name: string + sessionID?: string +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +describe('OpenCode plugin fail-open ownership', () => { + let tempDir: string + let posts: RecordedPost[] + let savedEnv: Record + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-fail-open-plugin-')) + posts = [] + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + savedFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + function readPayload(init?: RequestInit): RecordedPost { + return JSON.parse(String(init?.body)).payload as RecordedPost + } + + async function loadHooks( + list: () => Promise<{ data: SessionFixture[] }> = async () => ({ + data: [{ id: 'root' }] + }) + ): Promise { + return loadHooksWithSession({ list }) + } + + async function loadHooksWithSession(session: object): Promise { + return loadHooksWithContext({ client: { session } }) + } + + async function loadHooksWithContext(context: unknown): Promise { + const pluginPath = join(tempDir, 'orca-opencode-status.mjs') + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + const module = (await import(pathToFileURL(pluginPath).href)) as { + OrcaOpenCodeStatusPlugin: (ctx: unknown) => Promise + } + return module.OrcaOpenCodeStatusPlugin(context) + } + + function status(type: 'busy' | 'idle' | 'retry', sessionID = 'root'): PluginEvent { + return { + type: 'session.status', + properties: { sessionID, status: { type } } + } + } + + function names(): string[] { + return posts.map((post) => post.hook_event_name) + } + + function rejectWhenAborted(signal: AbortSignal | undefined): Promise { + if (!signal) { + return Promise.reject(new Error('missing abort signal')) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + } + + it('retires a provisional Busy when the factory has no SDK client', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ event: status('busy') }) + await hooks.event({ event: status('idle') }) + + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('keeps concurrent provisional Busy owners active until each exact session retires', async () => { + const first = await loadHooksWithContext(undefined) + const second = await loadHooksWithContext(undefined) + + await first.event({ event: status('busy', 'unknown-a') }) + await second.event({ event: status('busy', 'unknown-b') }) + await first.event({ event: status('idle', 'unknown-a') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await second.event({ event: status('idle', 'unknown-b') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionIdle']) + }) + + it('keeps same-session provisional Busy ownership separate across factories', async () => { + const first = await loadHooksWithContext(undefined) + const second = await loadHooksWithContext(undefined) + + await first.event({ event: status('busy', 'same-session') }) + await second.event({ event: status('busy', 'same-session') }) + await first.event({ event: status('idle', 'same-session') }) + expect(names()).toEqual(['SessionBusy']) + + await second.event({ event: status('idle', 'same-session') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('does not evict an active provisional owner under high concurrency', async () => { + const hooks = await loadHooksWithContext(undefined) + + for (let index = 0; index < 129; index += 1) { + await hooks.event({ event: status('busy', `unknown-${index}`) }) + } + for (let index = 1; index < 129; index += 1) { + await hooks.event({ event: status('idle', `unknown-${index}`) }) + } + expect(names().at(-1)).toBe('SessionBusy') + expect(posts.at(-1)?.sessionID).toBe('unknown-0') + + await hooks.event({ event: status('idle', 'unknown-0') }) + expect(names().at(-1)).toBe('SessionIdle') + }) + + it('drains an older preview before unknown Idle retires its exact known root', async () => { + const sessions = Array.from({ length: 129 }, (_, index) => ({ id: `root-${index}` })) + let lookupFails = false + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: sessions } + }) + let releasePart: (() => void) | undefined + const delayedPart = new Promise((resolve) => { + releasePart = resolve + }) + let notifyPartStarted: (() => void) | undefined + const partStarted = new Promise((resolve) => { + notifyPartStarted = resolve + }) + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + if (payload.hook_event_name === 'MessagePart') { + notifyPartStarted?.() + await delayedPart + } + posts.push(payload) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const hooks = await loadHooks(list) + + await hooks.event({ event: status('busy', 'root-0') }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root-0', + info: { id: 'assistant-message', role: 'assistant' } + } + } + }) + const preview = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root-0', + part: { + type: 'text', + text: 'older preview', + messageID: 'assistant-message' + } + } + } + }) + await partStarted + + // Why: evict only root-0's ancestry cache entry while its preview is in flight. + for (let index = 1; index < sessions.length; index += 1) { + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: `root-${index}`, + info: { id: `message-${index}`, role: 'assistant' } + } + } + }) + } + lookupFails = true + const callsBeforeIdle = list.mock.calls.length + const idle = hooks.event({ event: status('idle', 'root-0') }) + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(callsBeforeIdle + 1)) + await new Promise((resolve) => { + setImmediate(resolve) + }) + const namesBeforeRelease = names() + + releasePart?.() + await Promise.all([preview, idle]) + + expect(namesBeforeRelease).toEqual(['SessionBusy']) + expect(names()).toEqual(['SessionBusy', 'MessagePart', 'SessionIdle']) + }) + + it('drops a delayed text handler after its Waiting blocker resolves to Idle', async () => { + let lookupBlocks = false + let releaseLookup: (() => void) | undefined + const delayedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + let notifyLookupStarted: (() => void) | undefined + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + const list = vi.fn(async () => { + if (!lookupBlocks) { + throw new Error('lookup unavailable') + } + notifyLookupStarted?.() + await delayedLookup + return { data: [{ id: 'root' }] } + }) + const hooks = await loadHooks(list) + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root', + info: { id: 'assistant-message', role: 'assistant' } + } + } + }) + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-root', sessionID: 'root' } + } + }) + lookupBlocks = true + const preview = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { + type: 'text', + text: 'stale preview', + messageID: 'assistant-message' + } + } + } + }) + await lookupStarted + + await hooks.event({ + event: { + type: 'question.replied', + properties: { requestID: 'question-root', sessionID: 'root' } + } + }) + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + + releaseLookup?.() + await preview + + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + }) + + it('disposes only its provisional Busy owner and reasserts the survivor', async () => { + const first = await loadHooksWithContext(undefined) + const second = await loadHooksWithContext(undefined) + + await first.event({ event: status('busy', 'unknown-a') }) + await second.event({ event: status('busy', 'unknown-b') }) + await second.dispose?.() + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('unknown-a') + + await first.dispose?.() + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionBusy', 'SessionIdle']) + }) + + it('lets exact unknown-session Idle clear a blocker without SDK lookup', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-unknown', sessionID: 'unknown' } + } + }) + await hooks.event({ event: status('idle', 'unknown') }) + + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + }) + + it('does not let another unknown session Busy overwrite an active blocker', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ + event: { + type: 'permission.asked', + properties: { id: 'permission-root', sessionID: 'root' } + } + }) + await hooks.event({ event: status('busy', 'other-session') }) + + expect(names()).toEqual(['PermissionRequest']) + }) + + it('ignores malformed attention that has no required sessionID', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-without-session' } + } + }) + await hooks.event({ + event: { + type: 'permission.asked', + properties: { id: 'permission-without-session' } + } + }) + + expect(names()).toEqual([]) + }) + + it('retires provisional Busy after both ancestry lookups time out', async () => { + vi.useFakeTimers() + const list = vi.fn((options?: { signal?: AbortSignal }) => rejectWhenAborted(options?.signal)) + const hooks = await loadHooksWithSession({ list }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + expect(names()).toEqual(['SessionBusy']) + + const idle = hooks.event({ event: status('idle') }) + await vi.advanceTimersByTimeAsync(2_000) + await idle + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('promotes provisional Busy when later lookup confirms the root', async () => { + let lookupFails = true + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: [{ id: 'root' }] } + }) + const hooks = await loadHooksWithSession({ list }) + + await hooks.event({ event: status('busy') }) + lookupFails = false + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-root', sessionID: 'root' } + } + }) + await hooks.event({ + event: { + type: 'question.replied', + properties: { requestID: 'question-root', sessionID: 'root' } + } + }) + await hooks.event({ event: status('idle') }) + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy', 'SessionIdle']) + }) + + it('keeps a confirmed child provisionally Busy until its matching Idle', async () => { + let lookupFails = true + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: [{ id: 'root' }, { id: 'child', parentID: 'root' }] } + }) + const hooks = await loadHooksWithSession({ list }) + + await hooks.event({ event: status('busy', 'child') }) + lookupFails = false + await hooks.event({ event: status('busy', 'child') }) + expect(names()).toEqual(['SessionBusy']) + + await hooks.event({ event: status('idle', 'child') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) +}) diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts new file mode 100644 index 00000000000..b6684ea38af --- /dev/null +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -0,0 +1,765 @@ +/** + * Executes the generated OpenCode plugin source because this delivery state + * lives inside OpenCode's process, not in Orca's TypeScript runtime. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type SessionFixture = { id: string; parentID?: string } +type PluginEvent = { type: string; properties?: Record } +type PluginEventHandler = (input: { event: PluginEvent }) => Promise +type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } +type RecordedPost = { + hook_event_name: string + sessionID?: string +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +describe('OpenCode plugin lifecycle delivery', () => { + let tempDir: string + let posts: RecordedPost[] + let savedEnv: Record + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-lifecycle-plugin-')) + posts = [] + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + savedFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + function readPayload(init?: RequestInit): RecordedPost { + return JSON.parse(String(init?.body)).payload as RecordedPost + } + + async function loadHooks( + list: () => Promise<{ data: SessionFixture[] }> = async () => ({ + data: [{ id: 'root' }] + }) + ): Promise { + return loadHooksWithSession({ list }) + } + + async function loadHooksWithSession(session: object): Promise { + const pluginPath = join(tempDir, 'orca-opencode-status.mjs') + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + const module = (await import(pathToFileURL(pluginPath).href)) as { + OrcaOpenCodeStatusPlugin: (ctx: unknown) => Promise + } + return module.OrcaOpenCodeStatusPlugin({ client: { session } }) + } + + async function loadHandler( + list?: () => Promise<{ data: SessionFixture[] }> + ): Promise { + return (await loadHooks(list)).event + } + + function status(type: 'busy' | 'idle' | 'retry', sessionID = 'root'): PluginEvent { + return { + type: 'session.status', + properties: { sessionID, status: { type } } + } + } + + function delta(text: string, field = 'text', sessionID = 'root'): PluginEvent { + return { + type: 'message.part.delta', + properties: { + sessionID, + messageID: 'message-assistant', + partID: 'part-assistant', + field, + delta: text + } + } + } + + function names(): string[] { + return posts.map((post) => post.hook_event_name) + } + + function rejectWhenAborted(signal: AbortSignal | undefined): Promise { + if (!signal) { + return Promise.reject(new Error('missing abort signal')) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + } + + it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => { + let releaseFirstLookup: (() => void) | undefined + const firstLookup = new Promise((resolve) => { + releaseFirstLookup = resolve + }) + let notifyFirstLookupStarted: (() => void) | undefined + const firstLookupStarted = new Promise((resolve) => { + notifyFirstLookupStarted = resolve + }) + let calls = 0 + const list = vi.fn(async () => { + calls += 1 + if (calls === 1) { + notifyFirstLookupStarted?.() + await firstLookup + } + return { data: [{ id: 'root' }] } + }) + const handler = await loadHandler(list) + + const busy = handler({ event: status('busy') }) + await firstLookupStarted + const idle = handler({ event: status('idle') }) + + try { + // Why: OpenCode does not await event hooks, so the plugin must prevent + // the later idle lookup from overtaking the blocked busy lookup. + expect(list).toHaveBeenCalledTimes(1) + expect(posts).toHaveLength(0) + } finally { + releaseFirstLookup?.() + } + + await Promise.all([busy, idle]) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('retries an undelivered Busy transition after a non-2xx response', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + expect(names()).toEqual(['SessionBusy']) + + await vi.advanceTimersByTimeAsync(499) + expect(names()).toEqual(['SessionBusy']) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('aborts a hung Busy post before delivering the queued Idle transition', async () => { + vi.useFakeTimers() + let attempts = 0 + let firstSignal: AbortSignal | undefined + let notifyFirstFetchStarted: (() => void) | undefined + const firstFetchStarted = new Promise((resolve) => { + notifyFirstFetchStarted = resolve + }) + globalThis.fetch = vi.fn((_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + if (attempts > 1) { + return Promise.resolve(new Response(null, { status: 204 })) + } + firstSignal = init?.signal ?? undefined + notifyFirstFetchStarted?.() + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + const busy = handler({ event: status('busy') }) + const idle = handler({ event: status('idle') }) + await firstFetchStarted + + expect(firstSignal).toBeDefined() + await vi.advanceTimersByTimeAsync(1_999) + expect(names()).toEqual(['SessionBusy']) + await vi.advanceTimersByTimeAsync(1) + await Promise.all([busy, idle]) + + expect(firstSignal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('lets Idle supersede a pending Busy retry', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + return new Response(null, { + status: payload.hook_event_name === 'SessionBusy' ? 503 : 204 + }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: status('idle') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + + // Why: a stale retry must not resurrect Working after OpenCode is done. + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('uses a current nonempty text delta to retry a dirty Busy transition immediately', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + expect(names()).toEqual(['SessionBusy']) + + // Why: current OpenCode streams deltas after an empty text-start part; the + // live delta proves work continued and should recover a lost Busy post. + await handler({ event: delta('hello') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('keeps backoff across OpenCode canonical and deprecated Idle duplicates', async () => { + vi.useFakeTimers() + let idleAttempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + if (payload.hook_event_name === 'SessionIdle') { + idleAttempts += 1 + return new Response(null, { status: idleAttempts === 1 ? 503 : 204 }) + } + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: status('idle') }) + await handler({ + event: { type: 'session.idle', properties: { sessionID: 'root' } } + }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + + await vi.advanceTimersByTimeAsync(499) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionIdle', 'SessionIdle']) + }) + + it('uses repeated Busy evidence to retry the same dirty state immediately', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: status('retry') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('does not turn empty or non-text deltas into immediate retry traffic', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: delta('reasoning', 'reasoning') }) + await handler({ event: delta('') }) + expect(names()).toEqual(['SessionBusy']) + + await vi.advanceTimersByTimeAsync(499) + expect(names()).toEqual(['SessionBusy']) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('backs off exponentially and caps outage retries at thirty seconds', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 503 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + expect(names()).toHaveLength(1) + + const retryDelays = [500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000] + for (const [index, delay] of retryDelays.entries()) { + await vi.advanceTimersByTimeAsync(delay - 1) + expect(names()).toHaveLength(index + 1) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toHaveLength(index + 2) + } + }) + + it('fails Busy open after a hung lookup and still lets queued Idle proceed', async () => { + vi.useFakeTimers() + let releaseLookup: (() => void) | undefined + const blockedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + let calls = 0 + const list = vi.fn(async () => { + calls += 1 + if (calls === 1) { + await blockedLookup + } + return { data: [{ id: 'root' }] } + }) + const handler = await loadHandler(list) + const busy = handler({ event: status('busy') }) + const idle = handler({ event: status('idle') }) + + await vi.advanceTimersByTimeAsync(1_999) + expect(list).toHaveBeenCalledTimes(1) + expect(posts).toHaveLength(0) + await vi.advanceTimersByTimeAsync(1) + try { + expect(list).toHaveBeenCalledTimes(2) + // Busy is safe to fail open: even a child means its root is working. + // Idle still requires a later confirmed-root lookup. + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + } finally { + releaseLookup?.() + } + await Promise.all([busy, idle]) + }) + + it('aborts a hung current-SDK point lookup through its second options argument', async () => { + vi.useFakeTimers() + let capturedParameters: unknown + let capturedSignal: AbortSignal | undefined + function get(parameters: unknown, options: { signal?: AbortSignal }) { + capturedParameters = parameters + capturedSignal = options?.signal + return rejectWhenAborted(capturedSignal) + } + const hooks = await loadHooksWithSession({ + get, + list: async () => ({ data: [{ id: 'root' }] }) + }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedParameters).toEqual({ sessionID: 'root' }) + expect(capturedSignal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('aborts a hung legacy-SDK point lookup through its one-object argument', async () => { + vi.useFakeTimers() + let capturedOptions: { path?: { id?: string }; signal?: AbortSignal } | undefined + function get(options: { path?: { id?: string }; signal?: AbortSignal }) { + capturedOptions = options + return rejectWhenAborted(options.signal) + } + const hooks = await loadHooksWithSession({ + get, + list: async () => ({ data: [{ id: 'root' }] }) + }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedOptions?.path).toEqual({ id: 'root' }) + expect(capturedOptions?.signal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('aborts a hung current-SDK list fallback through its second options argument', async () => { + vi.useFakeTimers() + let capturedParameters: unknown + let capturedSignal: AbortSignal | undefined + function list(parameters: unknown, options: { signal?: AbortSignal }) { + capturedParameters = parameters + capturedSignal = options?.signal + return rejectWhenAborted(capturedSignal) + } + const hooks = await loadHooksWithSession({ list }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedParameters).toEqual({}) + expect(capturedSignal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('aborts a hung legacy-SDK list fallback through its one-object argument', async () => { + vi.useFakeTimers() + let capturedOptions: { signal?: AbortSignal } | undefined + function list(options: { signal?: AbortSignal }) { + capturedOptions = options + return rejectWhenAborted(options.signal) + } + const hooks = await loadHooksWithSession({ list }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedOptions?.signal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('does not let a failed Busy retry overwrite delivered permission attention', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + const firstBusy = payload.hook_event_name === 'SessionBusy' && names().length === 1 + return new Response(null, { status: firstBusy ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ + event: { type: 'permission.asked', properties: { id: 'permission-1', sessionID: 'root' } } + }) + expect(names()).toEqual(['SessionBusy', 'PermissionRequest']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'PermissionRequest']) + }) + + it('coalesces sustained text-delta recovery before returning to backoff', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts < 3 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: delta('stream-0') }) + for (let index = 1; index < 50; index += 1) { + await handler({ event: delta(`stream-${String(index)}`) }) + } + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(999) + expect(names()).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionBusy']) + }) + + it('cancels lifecycle retries when OpenCode disposes the plugin', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 503 }) + }) as typeof globalThis.fetch + const hooks = await loadHooks() + + await hooks.event({ event: status('busy') }) + expect(hooks.dispose).toBeTypeOf('function') + await hooks.dispose?.() + await vi.advanceTimersByTimeAsync(60_000) + + expect(names()).toEqual(['SessionBusy']) + }) + + it('publishes final Idle when the last busy factory is disposed', async () => { + const hooks = await loadHooks() + + await hooks.event({ event: status('busy') }) + await hooks.dispose?.() + + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('publishes final Idle when MessagePart alone made the disposed factory Working', async () => { + const hooks = await loadHooks() + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'prompt before busy', messageID: 'user-message' } + } + } + }) + + await hooks.dispose?.() + + expect(names()).toEqual(['MessagePart', 'SessionIdle']) + }) + + it('reasserts an already-delivered Idle after a later MessagePart', async () => { + const hooks = await loadHooks() + await hooks.event({ event: status('busy') }) + await hooks.event({ event: status('idle') }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'late prompt', messageID: 'user-message' } + } + } + }) + + await hooks.dispose?.() + + expect(names()).toEqual(['SessionBusy', 'SessionIdle', 'MessagePart', 'SessionIdle']) + }) + + it('lets OpenCode duplicate Idle supersede a later-delivered MessagePart', async () => { + const hooks = await loadHooks() + await hooks.event({ event: status('busy') }) + await hooks.event({ event: status('idle') }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'late prompt', messageID: 'user-message' } + } + } + }) + + await hooks.event({ event: { type: 'session.idle', properties: { sessionID: 'root' } } }) + + expect(names()).toEqual(['SessionBusy', 'SessionIdle', 'MessagePart', 'SessionIdle']) + }) + + it('does not treat a failed MessagePart attempt as delivered Working authority', async () => { + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + return new Response(null, { + status: payload.hook_event_name === 'MessagePart' ? 503 : 204 + }) + }) as typeof globalThis.fetch + const hooks = await loadHooks() + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'undelivered prompt', messageID: 'user-message' } + } + } + }) + + await hooks.dispose?.() + + expect(names()).toEqual(['MessagePart']) + }) + + it('does not publish a MessagePart after its factory is disposed during lookup', async () => { + let releaseLookup: (() => void) | undefined + const blockedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + let calls = 0 + const list = vi.fn(async () => { + calls += 1 + if (calls === 2) { + await blockedLookup + } + return { data: [{ id: 'seed' }, { id: 'root' }] } + }) + const hooks = await loadHooks(list) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'seed', info: { id: 'user-message', role: 'user' } } + } + }) + + const latePart = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'late prompt', messageID: 'user-message' } + } + } + }) + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2)) + await hooks.dispose?.() + releaseLookup?.() + await latePart + + expect(names()).toEqual([]) + }) + + it('drops a disposed factory pending preview while another factory stays active', async () => { + vi.useFakeTimers() + const first = await loadHooks() + const second = await loadHooks() + await first.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root', + info: { id: 'assistant-message', role: 'assistant' } + } + } + }) + const part = (text: string): PluginEvent => ({ + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text, messageID: 'assistant-message' } + } + }) + + await first.event({ event: part('first preview') }) + await first.event({ event: part('pending preview') }) + await first.dispose?.() + await vi.advanceTimersByTimeAsync(1_000) + await second.dispose?.() + + expect(names()).toEqual(['MessagePart', 'SessionIdle']) + }) + + it('keeps the pane Busy until every concurrent root session is idle', async () => { + const sessions = [{ id: 'root-a' }, { id: 'root-b' }] + const handler = await loadHandler(async () => ({ data: sessions })) + + await handler({ event: status('busy', 'root-a') }) + await handler({ event: status('busy', 'root-b') }) + await handler({ event: status('idle', 'root-a') }) + expect(names()).not.toContain('SessionIdle') + + await handler({ event: status('idle', 'root-b') }) + expect(names().filter((name) => name === 'SessionIdle')).toHaveLength(1) + expect(names().at(-1)).toBe('SessionIdle') + }) + + it('reasserts Busy through the refreshed endpoint after a live text delta', async () => { + const deliveries: { url: string; token: string | null }[] = [] + globalThis.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + deliveries.push({ + url: String(url), + token: new Headers(init?.headers).get('X-Orca-Agent-Hook-Token') + }) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + process.env.ORCA_AGENT_HOOK_PORT = '56789' + process.env.ORCA_AGENT_HOOK_TOKEN = 'refreshed-token' + await handler({ event: delta('still working') }) + + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + expect(deliveries).toEqual([ + { url: 'http://127.0.0.1:45678/hook/opencode', token: 'test-token' }, + { url: 'http://127.0.0.1:56789/hook/opencode', token: 'refreshed-token' } + ]) + }) + + it('clears unanswered question attention on authoritative root Idle', async () => { + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ + event: { type: 'question.asked', properties: { id: 'question-1', sessionID: 'root' } } + }) + expect(names().at(-1)).toBe('AskUserQuestion') + + // OpenCode can finish the question tool without question.replied. + await handler({ event: status('idle') }) + expect(names().at(-1)).toBe('SessionIdle') + }) +}) diff --git a/src/main/opencode/hook-plugin-message-part-throttle.test.ts b/src/main/opencode/hook-plugin-message-part-throttle.test.ts index d53fe826665..2468e7457e1 100644 --- a/src/main/opencode/hook-plugin-message-part-throttle.test.ts +++ b/src/main/opencode/hook-plugin-message-part-throttle.test.ts @@ -167,6 +167,82 @@ describe('OpenCode plugin MessagePart throttling', () => { expect(posts[1].body.payload.text).toBe('first final') }) + it('waits for an in-flight preview before delivering SessionIdle', async () => { + let releasePart: (() => void) | undefined + const delayedPart = new Promise((resolve) => { + releasePart = resolve + }) + globalThis.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const post = { url: String(url), body: JSON.parse(String(init?.body)) } as RecordedPost + posts.push(post) + if (post.body.payload.hook_event_name === 'MessagePart') { + await delayedPart + } + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadPluginEventHandler() + await seedAssistantRole(handler) + await handler({ + event: { + type: 'session.status', + properties: { sessionID: 'session-1', status: { type: 'busy' } } + } + }) + posts.length = 0 + + await handler(assistantPartEvent('completed reply')) + const idle = handler({ + event: { type: 'session.idle', properties: { sessionID: 'session-1' } } + }) + await vi.advanceTimersByTimeAsync(0) + expect(posts.map((post) => post.body.payload.hook_event_name)).toEqual(['MessagePart']) + + releasePart?.() + await idle + expect(posts.map((post) => post.body.payload.hook_event_name)).toEqual([ + 'MessagePart', + 'SessionIdle' + ]) + }) + + it('clears question attention when its tool part completes without a reply event', async () => { + const handler = await loadPluginEventHandler() + await handler({ + event: { + type: 'session.status', + properties: { sessionID: 'session-1', status: { type: 'busy' } } + } + }) + await handler({ + event: { + type: 'question.asked', + properties: { + id: 'question-1', + sessionID: 'session-1', + tool: { messageID: 'message-1', callID: 'call-1' } + } + } + }) + expect(posts.at(-1)?.body.payload.hook_event_name).toBe('AskUserQuestion') + + await handler({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'session-1', + part: { + type: 'tool', + tool: 'question', + messageID: 'message-1', + callID: 'call-1', + state: { status: 'completed' } + } + } + } + }) + expect(posts.at(-1)?.body.payload.hook_event_name).toBe('SessionBusy') + }) + it('posts user prompts immediately without consuming the assistant throttle slot', async () => { const handler = await loadPluginEventHandler() await handler({ diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index f3956519ee7..7d88e98e2cd 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,10 +32,23 @@ describe('OpenCode hook plugin source', () => { const source = _internals.getOpenCodePluginSource() expect(source).toContain('async function isChildSession(client, sessionID)') - expect(source).toContain('const sessions = await client.session.list();') - expect(source).toContain('const isChild = !!session?.parentID;') - expect(source).toContain('if (sessionID && (await isChildSession(client, sessionID))) {') - expect(source).toContain('return true;') + expect(source).toContain('walkSessionParents(client, sessionID, controller.signal)') + expect(source).toContain('currentSessionID = session.parentID;') + expect(source).toContain('rememberSessionRoot(id, currentSessionID)') + expect(source).toContain('{ path: { id: sessionID }, signal }') + expect(source).toContain('[{ sessionID }, { signal }]') + expect(source.indexOf('[{ sessionID }, { signal }]')).toBeLessThan( + source.indexOf('{ path: { id: sessionID }, signal }') + ) + expect(source).toContain('return client.session.list({}, { signal });') + expect(source).toContain('return client.session.list({ signal });') + expect(source).toContain('return rootSessionID === null ? null : rootSessionID !== sessionID;') + expect(source).toContain( + 'if (sessionID && (await isChildSession(client, sessionID)) !== false) {' + ) + expect(source).toContain( + 'if (sessionID && (await isChildSession(client, sessionID)) !== false) {\n return;\n }' + ) }) it('still accepts an optional opaque plugin context instead of destructuring', () => { @@ -86,20 +99,27 @@ describe('OpenCode hook plugin source', () => { // Why: forward question.asked too (not just permission.asked), else the pane stays "working" while the agent idles on a human reply. const source = _internals.getOpenCodePluginSource() - expect(source).toContain('if (event.type === "question.asked")') - expect(source).toContain('await post("AskUserQuestion", event.properties || {});') + expect(source).toContain('event.type === "question.asked"') + expect(source).toContain( + 'event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion"' + ) + expect(source).toContain('await setAttention(') }) it('forwards sessionID on status and message posts for resume metadata', () => { const source = _internals.getOpenCodePluginSource() expect(source).toContain( - 'await post("MessagePart", { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID });' + '{ role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },' ) expect(source).toContain('messageID: pending.messageID,') expect(source).toContain('sessionID: pending.sessionID,') - expect(source).toContain('await setStatus("busy", { sessionID });') - expect(source.match(/await setStatus\("idle", \{ sessionID \}\);/g) ?? []).toHaveLength(2) + expect(source).toContain( + 'await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);' + ) + expect(source).toContain( + 'await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);' + ) }) it('guards endpoint-file parse warnings with a process-lifetime latch', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index c2baa488dc9..32781775a87 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -128,12 +128,48 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' };', '}', '', + 'function hookEndpointKey() {', + ' const coords = resolveHookCoords();', + ' return [coords.port || "", coords.token || "", coords.env, coords.version].join("\\u0000");', + '}', + '', 'function getStatusType(event) {', ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', '}', '', - 'let lastStatus = "idle";', - 'const childSessionById = new Map();', + 'const HOOK_POST_TIMEOUT_MS = 2000;', + 'const SESSION_LOOKUP_TIMEOUT_MS = 2000;', + 'const MAX_SESSION_ANCESTRY_DEPTH = 32;', + 'const STATUS_RETRY_BASE_MS = 500;', + 'const STATUS_RETRY_MAX_MS = 30000;', + 'let desiredStatus = "idle";', + 'let desiredHookEventName = "SessionIdle";', + 'let desiredStatusKey = "idle:";', + 'let desiredStatusProperties = {};', + 'let desiredFactoryID = null;', + 'let deliveredStatusKey = "idle:";', + 'let deliveredEndpointKey = "";', + 'let statusDeliveryDirty = false;', + 'let statusRevision = 0;', + 'let statusRetryAttempt = 0;', + 'let statusRetryTimer = null;', + 'let lifecycleQueue = Promise.resolve();', + 'let busyRecoveryQueued = false;', + 'let busyRecoveryUsed = false;', + 'let busyRecoveryEndpointKey = "";', + 'let stateArrivalRevision = 0;', + '// Why: OpenCode can create directory-scoped factories and concurrent root', + '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', + 'let nextFactoryID = 0;', + 'const activeFactoryIDs = new Set();', + 'const disposingFactoryIDs = new Set();', + 'const busyRootOwnerBySessionID = new Map();', + '// Why: a matching Idle must retire fail-open Busy even when the SDK client', + '// is unavailable, without granting an unrelated unknown Idle authority.', + 'const provisionalBusyByKey = new Map();', + 'const pendingAttentionByKey = new Map();', + 'const rootSessionById = new Map();', + 'const rootSessionLookupById = new Map();', '', '// Why: message.part.updated re-sends the FULL accumulated text of the part', '// after every streamed append, so posting each event forwards O(n^2) bytes', @@ -146,27 +182,49 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { 'const MESSAGE_PART_MAX_CHARS = 4000;', 'let pendingAssistantPart = null;', 'let assistantPartFlushTimer = null;', + 'let messagePartPostInFlight = null;', + 'let deliveredMessagePartFactoryID = null;', 'let lastAssistantPartPostAt = 0;', '', 'function capMessagePartText(text) {', ' return text.length > MESSAGE_PART_MAX_CHARS ? text.slice(0, MESSAGE_PART_MAX_CHARS) : text;', '}', '', - 'async function flushPendingAssistantPart() {', + 'async function postMessagePart(properties, factoryID) {', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' const delivery = post("MessagePart", properties);', + ' messagePartPostInFlight = delivery;', + ' try {', + ' const delivered = await delivery;', + ' if (delivered) deliveredMessagePartFactoryID = factoryID;', + ' } finally {', + ' if (messagePartPostInFlight === delivery) messagePartPostInFlight = null;', + ' }', + '}', + '', + 'async function flushPendingAssistantPart(force = false) {', ' if (assistantPartFlushTimer) {', ' clearTimeout(assistantPartFlushTimer);', ' assistantPartFlushTimer = null;', ' }', + ' // Why: an idle/waiting transition must wait for every older preview;', + ' // keep one post in flight while later snapshots coalesce in memory.', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', ' const pending = pendingAssistantPart;', ' pendingAssistantPart = null;', ' if (!pending) return;', + ' if (', + ' !activeFactoryIDs.has(pending.factoryID) ||', + ' disposingFactoryIDs.has(pending.factoryID)', + ' ) return;', + ' if (!force && pending.authorityRevision !== stateArrivalRevision) return;', ' lastAssistantPartPostAt = Date.now();', - ' await post("MessagePart", {', + ' await postMessagePart({', ' role: pending.role,', ' text: capMessagePartText(pending.text),', ' messageID: pending.messageID,', ' sessionID: pending.sessionID,', - ' });', + ' }, pending.factoryID);', '}', '', 'function queueAssistantPart(part) {', @@ -204,29 +262,114 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { '// Why: oh-my-opencode style tools spawn child sessions that emit their', '// own session.idle / message events. Those child completions must not', '// flip the root Orca pane to done or overwrite the parent turn preview.', - '// Detect child sessions by checking `parentID` via client.session.list(),', - '// cache the result per session, and fail closed (assume child) on lookup errors', - '// so a transient SDK failure cannot create false "done" transitions.', - 'async function isChildSession(client, sessionID) {', - ' if (!sessionID) return true;', - ' if (childSessionById.has(sessionID)) return childSessionById.get(sessionID);', - ' if (!client?.session?.list) return true;', + '// Resolve the full parentID chain so descendant attention can be attributed', + '// to the root while child completion and previews remain non-authoritative.', + 'async function resolveRootSessionID(client, sessionID) {', + ' if (!sessionID) return null;', + ' if (rootSessionById.has(sessionID)) return rootSessionById.get(sessionID);', + ' if (!client?.session?.get && !client?.session?.list) return null;', + ' if (rootSessionLookupById.has(sessionID)) return rootSessionLookupById.get(sessionID);', + ' const lookup = lookupRootSessionID(client, sessionID);', + ' rootSessionLookupById.set(sessionID, lookup);', ' try {', - ' const sessions = await client.session.list();', - ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', - ' const session = list.find((entry) => entry?.id === sessionID);', - ' const isChild = !!session?.parentID;', - ' if (childSessionById.size >= 128) {', - ' const first = childSessionById.keys().next().value;', - ' if (first !== undefined) childSessionById.delete(first);', + ' return await lookup;', + ' } finally {', + ' if (rootSessionLookupById.get(sessionID) === lookup) {', + ' rootSessionLookupById.delete(sessionID);', ' }', - ' childSessionById.set(sessionID, isChild);', - ' return isChild;', - ' } catch {', - ' return true;', ' }', '}', '', + 'async function isChildSession(client, sessionID) {', + ' const rootSessionID = await resolveRootSessionID(client, sessionID);', + ' return rootSessionID === null ? null : rootSessionID !== sessionID;', + '}', + '', + 'function rememberSessionRoot(sessionID, rootSessionID) {', + ' if (rootSessionById.size >= 128 && !rootSessionById.has(sessionID)) {', + ' const first = rootSessionById.keys().next().value;', + ' if (first !== undefined) rootSessionById.delete(first);', + ' }', + ' rootSessionById.set(sessionID, rootSessionID);', + '}', + '', + 'async function lookupRootSessionID(client, sessionID) {', + ' const controller = new AbortController();', + ' let timeout;', + ' const deadline = new Promise((_, reject) => {', + ' timeout = setTimeout(() => {', + ' controller.abort();', + ' reject(new Error("session lookup timed out"));', + ' }, SESSION_LOOKUP_TIMEOUT_MS);', + ' if (timeout.unref) timeout.unref();', + ' });', + ' try {', + ' const rootSessionID = await Promise.race([', + ' walkSessionParents(client, sessionID, controller.signal),', + ' deadline,', + ' ]);', + ' return rootSessionID;', + ' } catch {', + ' return null;', + ' } finally {', + ' clearTimeout(timeout);', + ' }', + '}', + '', + 'async function walkSessionParents(client, sessionID, signal) {', + ' const lineage = [];', + ' let currentSessionID = sessionID;', + ' // Why: malformed or unexpectedly deep ancestry must not monopolize the', + ' // lifecycle FIFO even when every individual SDK lookup succeeds.', + ' while (lineage.length < MAX_SESSION_ANCESTRY_DEPTH) {', + ' const cachedRoot = rootSessionById.get(currentSessionID);', + ' if (cachedRoot) {', + ' for (const id of lineage) rememberSessionRoot(id, cachedRoot);', + ' return cachedRoot;', + ' }', + ' if (lineage.includes(currentSessionID)) return null;', + ' lineage.push(currentSessionID);', + ' const sessions = await lookupSessionList(client, currentSessionID, signal);', + ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', + ' const session = list.find((entry) => entry?.id === currentSessionID);', + ' if (!session) return null;', + ' if (!session.parentID) {', + ' for (const id of lineage) rememberSessionRoot(id, currentSessionID);', + ' return currentSessionID;', + ' }', + ' currentSessionID = session.parentID;', + ' }', + ' return null;', + '}', + '', + 'async function lookupSessionList(client, sessionID, signal) {', + ' // Why: point lookup avoids the SDK list page dropping older children;', + ' // current SDKs put AbortSignal in a second options argument, while legacy', + ' // generated clients accept one request-options object.', + ' if (client?.session?.get) {', + ' const calls = client.session.get.length >= 2', + ' ? [', + ' [{ sessionID }, { signal }],', + ' [{ path: { id: sessionID }, signal }],', + ' ]', + ' : [[{ path: { id: sessionID }, signal }]];', + ' for (const args of calls) {', + ' try {', + ' const result = await client.session.get(...args);', + ' if (result?.data?.id === sessionID) return { data: [result.data] };', + ' } catch {', + ' if (signal.aborted) throw new Error("session lookup aborted");', + ' // Try the other supported SDK generation, then list fallback.', + ' }', + ' }', + ' }', + ' if (!client?.session?.list) return { data: [] };', + ' if (client.session.list.length >= 2) {', + ' return client.session.list({}, { signal });', + ' }', + ' return client.session.list({ signal });', + '}', + '', 'async function post(hookEventName, extraProperties) {', ' // Why: resolve coords per post — the endpoint file may have been', ' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs', @@ -234,7 +377,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' // the OpenCode process), not per-Orca-instance.', ' const coords = resolveHookCoords();', ' const paneKey = process.env.ORCA_PANE_KEY;', - ' if (!coords.port || !coords.token || !paneKey) return;', + ' if (!coords.port || !coords.token || !paneKey) return false;', ` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`, ' const body = JSON.stringify({', ' paneKey,', @@ -245,28 +388,421 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' version: coords.version,', ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', ' });', + ' const controller = new AbortController();', + ' const timeout = setTimeout(() => controller.abort(), HOOK_POST_TIMEOUT_MS);', + ' if (timeout.unref) timeout.unref();', ' try {', - ' await fetch(url, {', + ' const response = await fetch(url, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', ' "X-Orca-Agent-Hook-Token": coords.token,', ' },', ' body,', + ' signal: controller.signal,', ' });', + ' return response.ok;', ' } catch {', ' // Why: OpenCode session events must never fail the agent run just', ' // because Orca is unavailable or the local loopback request failed.', + ' return false;', + ' } finally {', + ' clearTimeout(timeout);', ' }', '}', '', - 'async function setStatus(next, extraProperties) {', - ' // Why: dedupe so a flurry of session.status idle events after a turn', - ' // does not spam the dashboard with redundant done transitions.', - ' if (lastStatus === next) return;', - ' lastStatus = next;', + 'function enqueueLifecycle(task) {', + ' // Why: OpenCode intentionally fire-and-forgets hook promises, so async', + ' // lookups and posts need their own FIFO to preserve event order.', + ' const run = lifecycleQueue.then(async () => {', + ' try {', + ' await task();', + ' } catch {', + ' // Hook delivery must never reject into OpenCode.', + ' }', + ' });', + ' lifecycleQueue = run;', + ' return run;', + '}', + '', + 'function clearStatusRetry() {', + ' if (statusRetryTimer) clearTimeout(statusRetryTimer);', + ' statusRetryTimer = null;', + '}', + '', + 'function scheduleStatusRetry(revision) {', + ' if (', + ' statusRetryTimer ||', + ' revision !== statusRevision ||', + ' !statusDeliveryDirty ||', + ' !activeFactoryIDs.has(desiredFactoryID)', + ' ) return;', + ' const delay = Math.min(', + ' STATUS_RETRY_BASE_MS * Math.pow(2, Math.min(statusRetryAttempt, 6)),', + ' STATUS_RETRY_MAX_MS', + ' );', + ' statusRetryAttempt = Math.min(statusRetryAttempt + 1, 7);', + ' statusRetryTimer = setTimeout(() => {', + ' statusRetryTimer = null;', + ' void enqueueLifecycle(async () => {', + ' if (', + ' revision !== statusRevision ||', + ' !statusDeliveryDirty ||', + ' !activeFactoryIDs.has(desiredFactoryID)', + ' ) return;', + ' await publishDesiredStatus(revision);', + ' });', + ' }, delay);', + ' if (statusRetryTimer.unref) statusRetryTimer.unref();', + '}', + '', + 'async function publishDesiredStatus(revision) {', + ' if (revision !== statusRevision) return;', + ' if (!activeFactoryIDs.has(desiredFactoryID)) return;', + ' const endpointKey = hookEndpointKey();', + ' if (', + ' !statusDeliveryDirty &&', + ' deliveredStatusKey === desiredStatusKey &&', + ' deliveredEndpointKey === endpointKey &&', + ' deliveredMessagePartFactoryID === null', + ' ) return;', + ' const delivered = await post(desiredHookEventName, desiredStatusProperties);', + ' if (revision !== statusRevision) return;', + ' if (!delivered) {', + ' statusDeliveryDirty = true;', + ' scheduleStatusRetry(revision);', + ' return;', + ' }', + ' clearStatusRetry();', + ' statusRetryAttempt = 0;', + ' deliveredStatusKey = desiredStatusKey;', + ' deliveredEndpointKey = endpointKey;', + ' deliveredMessagePartFactoryID = null;', + ' statusDeliveryDirty = false;', + '}', + '', + 'async function setDeliveryTarget(', + ' next,', + ' nextKey,', + ' hookEventName,', + ' extraProperties,', + ' factoryID', + ') {', + ' const endpointChanged = deliveredEndpointKey !== hookEndpointKey();', + ' if (', + ' nextKey === desiredStatusKey &&', + ' nextKey === deliveredStatusKey &&', + ' desiredFactoryID === factoryID &&', + ' !statusDeliveryDirty &&', + ' !endpointChanged &&', + ' deliveredMessagePartFactoryID === null', + ' ) return;', + ' const targetChanged = nextKey !== desiredStatusKey || desiredFactoryID !== factoryID;', + ' clearStatusRetry();', + ' if (targetChanged) {', + ' statusRetryAttempt = 0;', + ' busyRecoveryUsed = false;', + ' busyRecoveryEndpointKey = "";', + ' }', + ' desiredStatus = next;', + ' desiredHookEventName = hookEventName;', + ' desiredStatusKey = nextKey;', + ' desiredStatusProperties = extraProperties || {};', + ' desiredFactoryID = factoryID;', + ' statusDeliveryDirty =', + ' statusDeliveryDirty ||', + ' deliveredMessagePartFactoryID !== null ||', + ' deliveredStatusKey !== nextKey ||', + ' endpointChanged;', + ' const revision = ++statusRevision;', + ' await publishDesiredStatus(revision);', + '}', + '', + 'async function setStatus(next, extraProperties, factoryID) {', + ' const nextKey = next + ":" + (extraProperties?.sessionID || "");', ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', - ' await post(hookEventName, extraProperties);', + ' await setDeliveryTarget(next, nextKey, hookEventName, extraProperties, factoryID);', + '}', + '', + 'async function setAttention(hookEventName, properties, factoryID, sourceSessionID) {', + ' const requestID = properties?.id || properties?.sessionID || "";', + ' const requestKey = attentionKey(factoryID, hookEventName, requestID, sourceSessionID);', + ' await flushPendingAssistantPart(true);', + ' await setDeliveryTarget(', + ' "waiting",', + ' "waiting:" + requestKey,', + ' hookEventName,', + ' properties,', + ' factoryID', + ' );', + '}', + '', + 'function recoverBusyFromDelta(client, sessionID, factoryID) {', + ' if (busyRecoveryQueued) return lifecycleQueue;', + ' if (', + ' busyRecoveryUsed &&', + ' busyRecoveryEndpointKey === hookEndpointKey()', + ' ) return lifecycleQueue;', + ' busyRecoveryQueued = true;', + ' return enqueueLifecycle(async () => {', + ' try {', + ' if (!activeFactoryIDs.has(factoryID)) return;', + ' if (sessionID && (await isChildSession(client, sessionID)) === true) return;', + ' if (!activeFactoryIDs.has(factoryID)) return;', + ' const endpointKey = hookEndpointKey();', + ' const endpointChanged = deliveredEndpointKey !== endpointKey;', + ' if (desiredStatus !== "busy" || (!statusDeliveryDirty && !endpointChanged)) return;', + ' if (busyRecoveryUsed && !endpointChanged) return;', + ' busyRecoveryUsed = true;', + ' busyRecoveryEndpointKey = endpointKey;', + ' clearStatusRetry();', + ' statusDeliveryDirty = true;', + ' const revision = ++statusRevision;', + ' await publishDesiredStatus(revision);', + ' } finally {', + ' busyRecoveryQueued = false;', + ' }', + ' });', + '}', + '', + 'function currentAttention() {', + ' let latestQuestion = null;', + ' for (const attention of pendingAttentionByKey.values()) {', + ' if (attention.hookEventName === "PermissionRequest") return attention;', + ' latestQuestion = attention;', + ' }', + ' return latestQuestion;', + '}', + '', + 'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {', + ' // Why: custom plugins may reuse request IDs across sessions or factories;', + ' // JSON tuple identity prevents one owner from hiding another blocker.', + ' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);', + '}', + '', + 'function clearAttentionForSession(sessionID, factoryID) {', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' if (attention.sourceSessionID === sessionID && attention.factoryID === factoryID) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', + 'function clearQuestionForToolPart(part, sessionID, factoryID) {', + ' if (', + ' part?.type !== "tool" ||', + ' part.tool !== "question" ||', + ' (part.state?.status !== "completed" && part.state?.status !== "error")', + ' ) return null;', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' const tool = attention.properties?.tool;', + ' if (', + ' attention.hookEventName === "AskUserQuestion" &&', + ' attention.sourceSessionID === sessionID &&', + ' attention.factoryID === factoryID &&', + ' tool?.messageID === part.messageID &&', + ' tool?.callID === part.callID', + ' ) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', + 'function clearAttentionForResolution(event, sessionID, factoryID) {', + ' const hookEventName =', + ' event.type === "permission.replied" ? "PermissionRequest" : "AskUserQuestion";', + ' const requestID = event.properties?.requestID || "";', + ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', + ' const attention = pendingAttentionByKey.get(key);', + ' if (!attention || attention.sourceSessionID !== sessionID) return null;', + ' pendingAttentionByKey.delete(key);', + ' return attention.properties?.sessionID || sessionID;', + '}', + '', + 'function provisionalBusyKey(factoryID, sessionID) {', + ' return JSON.stringify([factoryID, sessionID || ""]);', + '}', + '', + 'function rememberProvisionalBusy(sessionID, factoryID) {', + ' const key = provisionalBusyKey(factoryID, sessionID);', + ' // Why: active ownership cannot be LRU-evicted without allowing false', + ' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.', + ' provisionalBusyByKey.delete(key);', + ' provisionalBusyByKey.set(key, { sessionID, factoryID });', + '}', + '', + 'function clearProvisionalBusy(sessionID, factoryID) {', + ' return provisionalBusyByKey.delete(provisionalBusyKey(factoryID, sessionID));', + '}', + '', + 'function clearKnownBusyRoot(sessionID, factoryID) {', + ' if (busyRootOwnerBySessionID.get(sessionID) !== factoryID) return false;', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' return true;', + '}', + '', + 'function latestBusyOwner() {', + ' let latest = null;', + ' for (const [sessionID, factoryID] of busyRootOwnerBySessionID) {', + ' latest = { sessionID, factoryID };', + ' }', + ' for (const provisional of provisionalBusyByKey.values()) {', + ' latest = provisional;', + ' }', + ' return latest;', + '}', + '', + 'async function publishAggregateStatus(fallbackFactoryID, preferredSessionID) {', + ' const attention = currentAttention();', + ' if (attention) {', + ' await setAttention(', + ' attention.hookEventName,', + ' attention.properties,', + ' attention.factoryID,', + ' attention.sourceSessionID', + ' );', + ' return;', + ' }', + ' const busyOwner = latestBusyOwner();', + ' if (busyOwner) {', + ' await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);', + ' return;', + ' }', + ' await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);', + '}', + '', + 'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {', + ' stateArrivalRevision += 1;', + ' // Why: exact blocker/Busy retirement is authoritative even if ancestry', + ' // lookup failed; every older preview must settle before its replacement.', + ' await flushPendingAssistantPart(true);', + ' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);', + '}', + '', + 'async function handleLifecycleEvent(client, event, factoryID) {', + ' const sessionID = event.properties?.sessionID;', + ' const statusType = getStatusType(event);', + ' const isResolutionEvent =', + ' event.type === "permission.replied" ||', + ' event.type === "question.replied" ||', + ' event.type === "question.rejected";', + ' if (isResolutionEvent) {', + ' // Why: the stored owner already identifies the root, so replies clear', + ' // immediately even when OpenCode session lookup is slow or unavailable.', + ' const rootSessionID = clearAttentionForResolution(event, sessionID, factoryID);', + ' if (rootSessionID) await publishOwnershipChange(factoryID, rootSessionID);', + ' return;', + ' }', + ' const isAttentionEvent =', + ' event.type === "permission.asked" ||', + ' event.type === "question.asked";', + " // Why: attention without OpenCode's required sessionID cannot be", + ' // correlated to a later reply/Idle, so it must not become UI authority.', + ' if (isAttentionEvent && !sessionID) return;', + ' const canFailOpen =', + ' statusType === "busy" || statusType === "retry" || isAttentionEvent;', + ' const rootSessionID = sessionID ? await resolveRootSessionID(client, sessionID) : null;', + ' const childState = rootSessionID === null ? null : rootSessionID !== sessionID;', + ' const isIdleEvent = event.type === "session.idle" || statusType === "idle";', + ' const resolvedProvisionalBusy =', + ' childState === false || (childState === true && isIdleEvent)', + ' ? clearProvisionalBusy(sessionID, factoryID)', + ' : false;', + ' if (resolvedProvisionalBusy && childState === false) {', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' }', + ' // Why: child work rolls up to the pane; ignore its normal lifecycle noise,', + ' // but preserve blockers that still require the pane owner to respond.', + ' if (childState === true && !isAttentionEvent) {', + ' let attentionRootSessionID = null;', + ' if (isIdleEvent) {', + ' attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', + ' }', + ' if (resolvedProvisionalBusy || attentionRootSessionID) {', + ' await publishOwnershipChange(factoryID, attentionRootSessionID || rootSessionID);', + ' }', + ' return;', + ' }', + ' if (childState === null && !canFailOpen) {', + ' if (isIdleEvent) {', + ' // Why: recorded ownership can safely retire a blocker during an SDK', + ' // outage without granting unknown child Idle authority over root state.', + ' const attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', + ' const clearedProvisionalBusy = clearProvisionalBusy(sessionID, factoryID);', + ' const clearedKnownBusyRoot = clearKnownBusyRoot(sessionID, factoryID);', + ' if (attentionRootSessionID || clearedProvisionalBusy || clearedKnownBusyRoot) {', + ' await publishOwnershipChange(factoryID, attentionRootSessionID || sessionID);', + ' }', + ' }', + ' return;', + ' }', + ' if (childState === null && (statusType === "busy" || statusType === "retry")) {', + ' // Unknown lineage may be child work, so keep exact provisional ownership', + ' // only until matching Idle/disposal; other blockers still take priority.', + ' clearAttentionForSession(sessionID, factoryID);', + ' rememberProvisionalBusy(sessionID, factoryID);', + ' await publishAggregateStatus(factoryID, sessionID);', + ' return;', + ' }', + ' if (event.type === "permission.asked" || event.type === "question.asked") {', + ' stateArrivalRevision += 1;', + ' // Why: attention must share the lifecycle FIFO and retry target so a', + ' // delayed Busy post cannot overwrite a newer human blocker.', + ' const hookEventName =', + ' event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion";', + ' // Why: show the blocker on the root turn while retaining its real child', + ' // owner for exact reply, tool-completion, and disposal cleanup.', + ' const properties = { ...(event.properties || {}), sessionID: rootSessionID || sessionID };', + ' const requestID = properties.id || sessionID || "";', + ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', + ' // Why: unresolved blockers are live UI authority and cannot be evicted;', + ' // reply, exact Idle, tool completion, or factory disposal retires them.', + ' pendingAttentionByKey.set(key, {', + ' hookEventName,', + ' properties,', + ' factoryID,', + ' sourceSessionID: sessionID,', + ' });', + ' await publishAggregateStatus(factoryID, rootSessionID || sessionID);', + ' return;', + ' }', + ' if (isIdleEvent) {', + ' stateArrivalRevision += 1;', + ' const idleKey = "idle:" + (sessionID || "");', + ' // Why: current OpenCode emits canonical idle followed by deprecated', + ' // session.idle; a failed canonical post should keep its backoff.', + ' if (', + ' event.type === "session.idle" &&', + ' desiredStatusKey === idleKey &&', + ' statusDeliveryDirty &&', + ' statusRetryTimer', + ' ) return;', + ' // Why: flush the coalesced final reply snapshot before the idle', + ' // transition so the done-state preview shows the completed message.', + ' await flushPendingAssistantPart(true);', + ' clearAttentionForSession(sessionID, factoryID);', + ' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' }', + ' await publishAggregateStatus(factoryID, sessionID);', + ' return;', + ' }', + ' // Why: recoverable compaction failures emit session.error and continue;', + ' // canonical session.status is the authority for actual completion.', + ' if (event.type === "session.error") return;', + ' if (statusType === "busy" || statusType === "retry") {', + ' clearAttentionForSession(sessionID, factoryID);', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' await publishAggregateStatus(factoryID, sessionID);', + ' }', '}', '', '// Why: accept the factory argument as an optional opaque parameter instead', @@ -276,9 +812,14 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { '// UnknownError before any event is ever dispatched.', 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', ' const client = _ctx?.client;', + ' const factoryID = ++nextFactoryID;', + ' activeFactoryIDs.add(factoryID);', + ' let disposed = false;', ' return {', ' event: async ({ event }) => {', - ' if (!event?.type) return;', + ' if (disposed || !event?.type) return;', + ' const authorityRevision = stateArrivalRevision;', + ' const statusType = getStatusType(event);', '', ' // Why: cache the message role BEFORE the async isChildSession check.', ' // OpenCode fires message.updated (user) and message.part.updated (text)', @@ -293,30 +834,59 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' }', '', ' const sessionID = event.properties?.sessionID;', - ' if (sessionID && (await isChildSession(client, sessionID))) {', + ' const updatedPart = event.properties?.part;', + ' if (', + ' event.type === "message.part.updated" &&', + ' updatedPart?.type === "tool" &&', + ' updatedPart.tool === "question" &&', + ' (updatedPart.state?.status === "completed" || updatedPart.state?.status === "error")', + ' ) {', + ' await enqueueLifecycle(async () => {', + ' if (disposed) return;', + ' // Why: stored ownership clears child questions without waiting on', + ' // ancestry lookup even though ordinary child message parts stay hidden.', + ' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);', + ' if (!rootSessionID) return;', + ' await publishOwnershipChange(factoryID, rootSessionID);', + ' });', ' return;', ' }', '', - ' if (event.type === "permission.asked") {', - ' // Why: permission asks are not a session state transition — emit', - ' // without mutating lastStatus so the next SessionBusy/SessionIdle', - ' // still fires. The server maps PermissionRequest to `waiting`.', - ' await post("PermissionRequest", event.properties || {});', + ' if (', + ' event.type === "session.status" ||', + ' event.type === "session.idle" ||', + ' event.type === "session.error" ||', + ' event.type === "permission.asked" ||', + ' event.type === "question.asked" ||', + ' event.type === "permission.replied" ||', + ' event.type === "question.replied" ||', + ' event.type === "question.rejected"', + ' ) {', + ' await enqueueLifecycle(() =>', + ' disposed ? undefined : handleLifecycleEvent(client, event, factoryID)', + ' );', ' return;', ' }', '', - ' if (event.type === "question.asked") {', - ' // Why: question.asked fires when OpenCode uses an ask-the-user tool', - ' // (distinct from permission.asked, which blocks on tool approval).', - ' // The agent is idle-but-waiting on a human reply, not running, so we', - ' // must flip the pane to the same red "needs attention" state used for', - ' // permission requests. Like permission.asked, do not touch lastStatus', - ' // so the next SessionBusy/SessionIdle after the user answers still', - ' // fires and restores the normal working/done flow.', - ' await post("AskUserQuestion", event.properties || {});', + ' if (event.type === "message.part.delta") {', + ' const properties = event.properties || {};', + ' if (', + ' properties.field === "text" &&', + ' typeof properties.delta === "string" &&', + ' properties.delta.length > 0', + ' ) {', + ' await recoverBusyFromDelta(client, sessionID, factoryID);', + ' }', ' return;', ' }', '', + ' if (sessionID && (await isChildSession(client, sessionID)) !== false) {', + ' return;', + ' }', + ' if (disposed) return;', + ' if (authorityRevision !== stateArrivalRevision) return;', + ' if (desiredStatus === "waiting") return;', + '', ' if (event.type === "message.updated") {', ' // Why: role is already cached above the isChildSession await so the', ' // back-to-back message.part.updated for the same messageID is not', @@ -343,31 +913,84 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' // Why: user prompts arrive as a single event, not a stream — post', ' // immediately (still capped) so the throttle slot stays free for', ' // the assistant reply that follows within the same window.', - ' await post("MessagePart", { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID });', + ' await postMessagePart(', + ' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },', + ' factoryID', + ' );', ' return;', ' }', - ' queueAssistantPart({ role, text: part.text, messageID: part.messageID, sessionID });', + ' queueAssistantPart({', + ' role,', + ' text: part.text,', + ' messageID: part.messageID,', + ' sessionID,', + ' authorityRevision,', + ' factoryID,', + ' });', ' return;', ' }', '', - ' if (event.type === "session.idle" || event.type === "session.error") {', - ' // Why: flush the coalesced final reply snapshot before the idle', - ' // transition so the done-state preview shows the completed message.', - ' await flushPendingAssistantPart();', - ' await setStatus("idle", { sessionID });', - ' return;', - ' }', - '', - ' if (event.type === "session.status") {', - ' const statusType = getStatusType(event);', - ' if (statusType === "busy" || statusType === "retry") {', - ' await setStatus("busy", { sessionID });', - ' return;', + ' },', + ' dispose: async () => {', + ' if (disposed) return;', + ' disposed = true;', + ' disposingFactoryIDs.add(factoryID);', + ' await enqueueLifecycle(async () => {', + ' // An older MessagePart must settle before disposal publishes the', + ' // replacement state, or its late Working update could win.', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {', + ' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);', ' }', - ' if (statusType === "idle") {', - ' await setStatus("idle", { sessionID });', + ' for (const [key, provisional] of provisionalBusyByKey) {', + ' if (provisional.factoryID === factoryID) provisionalBusyByKey.delete(key);', ' }', - ' }', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' if (attention.factoryID === factoryID) pendingAttentionByKey.delete(key);', + ' }', + ' if (pendingAssistantPart?.factoryID === factoryID) {', + ' if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer);', + ' assistantPartFlushTimer = null;', + ' pendingAssistantPart = null;', + ' }', + ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', + ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', + ' clearStatusRetry();', + ' statusRevision += 1;', + ' // A MessagePart may have changed the listener to Working after the', + ' // same lifecycle key was delivered; force that key to be reasserted.', + ' statusDeliveryDirty = ownsDeliveredMessagePart;', + ' busyRecoveryUsed = false;', + ' busyRecoveryEndpointKey = "";', + ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', + ' (id) => id !== factoryID', + ' );', + ' if (fallbackFactoryID !== undefined) {', + ' await publishAggregateStatus(', + ' fallbackFactoryID,', + ' desiredStatusProperties?.sessionID', + ' );', + ' } else {', + ' // Why: Instance disposal can happen while the PTY stays alive;', + ' // publish a final idle so Orca does not retain a dead owner.', + ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', + ' await setStatus(', + ' "idle",', + ' { sessionID: desiredStatusProperties?.sessionID },', + ' factoryID', + ' );', + ' }', + ' clearStatusRetry();', + ' desiredStatus = "idle";', + ' desiredHookEventName = "SessionIdle";', + ' desiredStatusKey = "idle:";', + ' desiredStatusProperties = {};', + ' desiredFactoryID = null;', + ' }', + ' }', + ' activeFactoryIDs.delete(factoryID);', + ' disposingFactoryIDs.delete(factoryID);', + ' });', ' },', ' };', '};', diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 593ba050b0b..d0f27346e83 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -94,7 +94,16 @@ import { type SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' -import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostOrder, + normalizeExecutionHostId, + normalizeVisibleExecutionHostIds, + toSshExecutionHostId, + type ExecutionHostId +} from '../shared/execution-host' import { getDefaultPersistedState, getDefaultNotificationSettings, @@ -116,14 +125,6 @@ import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostOrder, - normalizeExecutionHostId, - normalizeVisibleExecutionHostIds, - toSshExecutionHostId, - type ExecutionHostId -} from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' import { migrateUiHostScopeSshTargetId, @@ -193,6 +194,7 @@ import { normalizeWorkspaceStatuses } from '../shared/workspace-statuses' import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width' +import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width' import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership' import { sanitizeRepoIcon } from '../shared/repo-icon' import { normalizeRepoBadgeColor } from '../shared/repo-badge-color' @@ -5506,6 +5508,9 @@ export class Store { osc52ClipboardDefaultOnNoticePending: this.state.ui?.osc52ClipboardDefaultOnNoticePending === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + this.state.ui?.combinedDiffFileTreeWidth + ), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds ), @@ -5606,6 +5611,9 @@ export class Store { markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + sanitizedUpdates.combinedDiffFileTreeWidth ?? this.state.ui?.combinedDiffFileTreeWidth + ), visibleWorkspaceHostIds: updates.visibleWorkspaceHostIds !== undefined ? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds) diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index c3901090c33..40ad6978fcb 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -60,11 +60,10 @@ export class PluginContentPackRegistry { while (true) { const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean => approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) - await Promise.all([ - this.languagePacks.reconcile(discovered, approveAtomically), - this.vmRecipes.reconcile(discovered, approveAtomically), - this.commands.reconcile(discovered, approveAtomically, keybindings) - ]) + const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically) + const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically) + this.commands.reconcile(discovered, approveAtomically, keybindings) + await Promise.all([languagePacks, vmRecipes]) let foundNewError = false for (const pluginKey of approvedKeys) { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e31110525fa..5e29ee31eb7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */ -import { basename, delimiter } from 'node:path' +import { basename, delimiter, win32 as pathWin32 } from 'node:path' import { randomUUID } from 'node:crypto' -import { win32 as pathWin32 } from 'node:path' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' import { resolveEffectiveWindowsPowerShell, diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 5dc32a48ed3..9974669fbcd 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1009,7 +1009,7 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledTimes(1) pendingDiff.resolve() - await expect(Promise.all(reads)).resolves.toEqual(Array(8).fill(diff)) + await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) mux.request.mockReset() const branchDiffs = [diff] @@ -1026,7 +1026,9 @@ describe('SshGitProvider', () => { await waitForRequestCount(mux.request, 1) expect(mux.request).toHaveBeenCalledTimes(1) pendingBranchDiff.resolve() - await expect(Promise.all(branchReads)).resolves.toEqual(Array(8).fill(branchDiffs)) + await expect(Promise.all(branchReads)).resolves.toEqual( + Array.from({ length: 8 }, () => branchDiffs) + ) mux.request.mockReset() const pendingCommitDiff = deferredValue(diff) @@ -1043,7 +1045,7 @@ describe('SshGitProvider', () => { await waitForRequestCount(mux.request, 1) expect(mux.request).toHaveBeenCalledTimes(1) pendingCommitDiff.resolve() - await expect(Promise.all(commitReads)).resolves.toEqual(Array(8).fill(diff)) + await expect(Promise.all(commitReads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) }) it('retries diff RPCs after an in-flight rejection settles', async () => { diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index 236b21d4b25..69b502cdaf5 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,19 +1,25 @@ import { describe, expect, it, vi } from 'vitest' -const { defaultExecFileSyncMock } = vi.hoisted(() => ({ +const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ + defaultExecFileMock: vi.fn(), defaultExecFileSyncMock: vi.fn() })) vi.mock('node:child_process', () => ({ + execFile: defaultExecFileMock, execFileSync: defaultExecFileSyncMock })) import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPath, - readPersistedWindowsPathSegments + mergePersistedWindowsPathAsync, + readPersistedWindowsPathSegments, + readPersistedWindowsPathSegmentsAsync } from './windows-environment-path' +type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void + describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { const execFileSync = vi @@ -39,6 +45,10 @@ describe('readPersistedWindowsPathSegments', () => { }) expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin']) + expect(execFileSync).toHaveBeenCalledTimes(2) + expect( + execFileSync.mock.calls.every(([command]) => command === 'C:\\Windows\\System32\\reg.exe') + ).toBe(true) }) it('returns an empty list outside Windows', () => { @@ -48,6 +58,11 @@ describe('readPersistedWindowsPathSegments', () => { expect(execFileSync).not.toHaveBeenCalled() }) + it('does not start asynchronous registry reads outside Windows', async () => { + await expect(readPersistedWindowsPathSegmentsAsync({ platform: 'linux' })).resolves.toEqual([]) + expect(defaultExecFileMock).not.toHaveBeenCalled() + }) + it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -66,6 +81,164 @@ describe('readPersistedWindowsPathSegments', () => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) + + it('force-refreshes the production registry cache', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ + 'C:\\Machine', + 'C:\\User', + 'C:\\Program Files\\GitHub CLI' + ]) + expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps the last good segments when a forced read hits a blocked registry', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + .mockImplementation(() => { + throw new Error('ERROR: Access is denied.') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still clears the cache when the registry reports an empty Path', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + .mockReturnValue(' Path REG_SZ \r\n') + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + + // Why: an emptied persisted Path is a successful read, not a blocked one — + // it must not be mistaken for the failure case and served from the cache. + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([]) + expect(readPersistedWindowsPathSegments()).toEqual([]) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const callbacks: ExecCallback[] = [] + defaultExecFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callbacks.push(callback) + return {} as never + } + ) + __resetPersistedWindowsPathCacheForTests() + + try { + const firstEnv = { Path: 'C:\\First' } + const secondEnv = { Path: 'C:\\Second' } + const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) + const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) + + expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') + callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') + await Promise.all([first, second]) + expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') + expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps the last good cache when bounded asynchronous reads time out', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileMock + .mockImplementationOnce( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, ' Path REG_SZ C:\\Machine\r\n', '') + return {} as never + } + ) + .mockImplementationOnce( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, ' Path REG_SZ C:\\User\r\n', '') + return {} as never + } + ) + .mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') + return {} as never + } + ) + __resetPersistedWindowsPathCacheForTests() + + try { + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + expect(defaultExecFileMock).toHaveBeenCalledTimes(4) + expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) describe('mergePersistedWindowsPath', () => { diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 283367c1c68..df0b53d3476 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,19 +1,29 @@ -import { execFileSync } from 'node:child_process' +import { execFile, execFileSync } from 'node:child_process' +import { getRegExePath } from '../win32-utils' +type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync type ReadWindowsPathOptions = { + execFile?: ExecFile execFileSync?: ExecFileSync env?: NodeJS.ProcessEnv + forceRefresh?: boolean platform?: NodeJS.Platform } +type RegistryPathRead = { + failed: boolean + segments: string[] +} + const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], ['HKCU\\Environment', 'Path'] ] as const const PERSISTED_WINDOWS_PATH_CACHE_TTL_MS = 30_000 +const PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS = 5_000 let persistedWindowsPathCache: | { @@ -21,6 +31,7 @@ let persistedWindowsPathCache: segments: string[] } | undefined +let pendingPersistedWindowsPathRefresh: Promise | undefined function parseRegistryPathValue(output: string, valueName: string): string | null { const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') @@ -52,6 +63,60 @@ function splitPathSegments(pathValue: string, pathDelimiter: string): string[] { .filter(Boolean) } +function registryOutputSegments( + output: string, + valueName: string, + env: NodeJS.ProcessEnv, + pathDelimiter: string +): string[] { + const value = parseRegistryPathValue(output, valueName) + return value + ? splitPathSegments(expandWindowsEnvironmentVariables(value, env), pathDelimiter) + : [] +} + +function keepLastGoodSegments(segments: string[], failedReads: number): string[] { + if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { + return [...persistedWindowsPathCache.segments] + } + return segments +} + +function cachePersistedWindowsPathSegments(segments: string[], failedReads: number): string[] { + // Why: timeouts or policy blocks must not replace a usable cache, while successful empty + // registry values still need to remove stale entries. + const kept = keepLastGoodSegments(segments, failedReads) + persistedWindowsPathCache = { readAt: Date.now(), segments: [...kept] } + return [...kept] +} + +function readRegistryPathAsync( + run: ExecFile, + executable: string, + registryValue: readonly [key: string, valueName: string], + env: NodeJS.ProcessEnv, + pathDelimiter: string +): Promise { + const [key, valueName] = registryValue + return new Promise((resolve) => { + run( + executable, + ['query', key, '/v', valueName], + { encoding: 'utf8', timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + if (error) { + resolve({ failed: true, segments: [] }) + return + } + resolve({ + failed: false, + segments: registryOutputSegments(String(stdout), valueName, env, pathDelimiter) + }) + } + ) + }) +} + export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions = {}): string[] { const platform = options.platform ?? process.platform if (platform !== 'win32') { @@ -59,11 +124,13 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions } const useProductionCache = + options.execFile === undefined && options.execFileSync === undefined && options.env === undefined && options.platform === undefined const now = Date.now() if ( + !options.forceRefresh && useProductionCache && persistedWindowsPathCache && now - persistedWindowsPathCache.readAt < PERSISTED_WINDOWS_PATH_CACHE_TTL_MS @@ -71,44 +138,131 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } + if ( + !options.forceRefresh && + useProductionCache && + pendingPersistedWindowsPathRefresh && + persistedWindowsPathCache + ) { + // Why: synchronous PTY construction cannot await the active refresh; its stale cache is + // safer than duplicating the registry read on Electron's main thread. + return [...persistedWindowsPathCache.segments] + } + const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) const segments: string[] = [] + let failedReads = 0 for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { try { - const output = run('reg.exe', ['query', key, '/v', valueName], { + const output = run(getRegExePath(env), ['query', key, '/v', valueName], { encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, windowsHide: true }) - const value = parseRegistryPathValue(output, valueName) - if (value) { - segments.push( - ...splitPathSegments(expandWindowsEnvironmentVariables(value, env), pathDelimiter) - ) - } + segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) } catch { // Registry access can fail in stripped test containers or remote-like // Windows contexts. Existing PATH remains the fallback in those cases. + failedReads += 1 } } - if (useProductionCache) { - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. - persistedWindowsPathCache = { - readAt: now, - segments: [...segments] - } + if (!useProductionCache) { + return segments } - return segments + // Why: local PTY spawn is a hot path on Windows, and each uncached read + // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps + // terminal bursts cheap while still picking up newly installed CLIs soon. + return cachePersistedWindowsPathSegments(segments, failedReads) +} + +export async function readPersistedWindowsPathSegmentsAsync( + options: ReadWindowsPathOptions = {} +): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return [] + } + + const useProductionCache = + options.execFile === undefined && + options.execFileSync === undefined && + options.env === undefined && + options.platform === undefined + const now = Date.now() + if ( + !options.forceRefresh && + useProductionCache && + persistedWindowsPathCache && + now - persistedWindowsPathCache.readAt < PERSISTED_WINDOWS_PATH_CACHE_TTL_MS + ) { + return [...persistedWindowsPathCache.segments] + } + if (useProductionCache && pendingPersistedWindowsPathRefresh) { + return [...(await pendingPersistedWindowsPathRefresh)] + } + + const run = options.execFile ?? execFile + const env = options.env ?? process.env + const pathDelimiter = getPathDelimiter(platform) + const executable = getRegExePath(env) + const refresh = Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) + ) + ).then((reads) => { + const segments = reads.flatMap((read) => read.segments) + if (!useProductionCache) { + return segments + } + return cachePersistedWindowsPathSegments(segments, reads.filter((read) => read.failed).length) + }) + + if (!useProductionCache) { + return refresh + } + pendingPersistedWindowsPathRefresh = refresh + try { + return [...(await refresh)] + } finally { + if (pendingPersistedWindowsPathRefresh === refresh) { + pendingPersistedWindowsPathRefresh = undefined + } + } } export function __resetPersistedWindowsPathCacheForTests(): void { persistedWindowsPathCache = undefined + pendingPersistedWindowsPathRefresh = undefined +} + +function mergeWindowsPathSegments( + env: NodeJS.ProcessEnv, + persistedSegments: string[], + platform: NodeJS.Platform, + sourceEnv: NodeJS.ProcessEnv +): void { + const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' + const pathDelimiter = getPathDelimiter(platform) + const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' + const currentSegments = splitPathSegments(currentPath, pathDelimiter) + const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) + const missing = persistedSegments.filter((segment) => { + const normalized = segment.toLowerCase() + if (existing.has(normalized)) { + return false + } + existing.add(normalized) + return true + }) + + if (missing.length > 0) { + env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) + } } export function mergePersistedWindowsPath( @@ -120,27 +274,22 @@ export function mergePersistedWindowsPath( return } - const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' - const pathDelimiter = getPathDelimiter(platform) const sourceEnv = options.env ?? process.env - const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' - const currentSegments = splitPathSegments(currentPath, pathDelimiter) - const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) - const missing = readPersistedWindowsPathSegments(options).filter((segment) => { - const normalized = segment.toLowerCase() - if (existing.has(normalized)) { - return false - } - existing.add(normalized) - return true - }) - - if (missing.length === 0) { - return - } - // Why: Windows broadcasts PATH changes to future processes, but a running // Electron app keeps its old environment. Append the persisted additions so // newly installed CLIs resolve without unexpectedly reordering existing PATH. - env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) + mergeWindowsPathSegments(env, readPersistedWindowsPathSegments(options), platform, sourceEnv) +} + +export async function mergePersistedWindowsPathAsync( + env: NodeJS.ProcessEnv, + options: ReadWindowsPathOptions = {} +): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + const sourceEnv = options.env ?? process.env + const persistedSegments = await readPersistedWindowsPathSegmentsAsync(options) + mergeWindowsPathSegments(env, persistedSegments, platform, sourceEnv) } diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 206748e1bc5..79ce437b2d6 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -20,14 +20,14 @@ import { readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import { readClaudeManagedAuthFile, resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from '../claude-accounts/managed-auth-path' -import { writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import { isOauthTokenExpiring, refreshClaudeOauthCredentials diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index e37c7106747..adbae1adf7e 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1286,7 +1286,7 @@ export class RuntimeFileCommands { callback: (events: FsChangeEvent[]) => void, onTerminalError: (error: Error) => void = () => undefined, signal?: AbortSignal - ): Promise<() => void> { + ): Promise<() => Promise> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') const open = async (): Promise<{ unsubscribe: () => Promise diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 1103852ed8a..544b9b6b217 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -19,14 +19,16 @@ const mocks = vi.hoisted(() => ({ generatePullRequestFieldsFromContext: vi.fn(), resolveCommitMessageSettings: vi.fn(), resolveHostedReviewBodyForGeneration: vi.fn(), - getSshGitProvider: vi.fn() + getSshGitProvider: vi.fn(), + getStatus: vi.fn() })) vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), abortMerge: mocks.abortMerge, abortRebase: mocks.abortRebase, - getStagedCommitContext: mocks.getStagedCommitContext + getStagedCommitContext: mocks.getStagedCommitContext, + getStatus: mocks.getStatus })) vi.mock('../git/checkout', () => ({ @@ -100,6 +102,7 @@ describe('RuntimeGitCommands', () => { mocks.resolveHostedReviewBodyForGeneration.mockReset() mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body) mocks.getSshGitProvider.mockReset() + mocks.getStatus.mockReset() mocks.checkoutBranch.mockReset() mocks.listLocalBranches.mockReset() }) @@ -121,6 +124,45 @@ describe('RuntimeGitCommands', () => { expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath, {}) }) + // Why: a directory-only ignore rule (`node_modules/`) never matches the shared + // symlink, so Git reports it untracked forever. Runtime/CLI status has to tell + // getStatus which untracked entries are Orca's own (issue #10451); nothing else + // asserts this call site supplies them. + it('passes the repo shared link paths through local runtime status', async () => { + mocks.getStatus.mockResolvedValue({ entries: [], conflictOperation: 'none' }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/workspace/feature'), + repo: { path: '/workspace/repo', symlinkPaths: ['node_modules'] } as never + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.getRuntimeGitStatus('id:wt-1') + + expect(mocks.getStatus).toHaveBeenCalledWith('/workspace/feature', { + sharedLinkPaths: ['node_modules'] + }) + }) + + it('does not resolve shared link paths for a remote runtime status', async () => { + const provider = { getStatus: vi.fn().mockResolvedValue({ entries: [] }) } + mocks.getSshGitProvider.mockReturnValue(provider) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/remote/repo'), + repo: { path: '/remote/repo', symlinkPaths: ['node_modules'] } as never, + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.getRuntimeGitStatus('id:wt-1') + + expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo') + expect(mocks.getStatus).not.toHaveBeenCalled() + }) + it('aborts a remote merge through the SSH git provider', async () => { const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) } mocks.getSshGitProvider.mockReturnValue(provider) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index a2dd698444f..fe1059962ec 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -58,6 +58,7 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' import { checkIgnoredPaths } from '../git/check-ignored-paths' +import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, @@ -196,9 +197,13 @@ export class RuntimeGitCommands { : provider.getStatus(target.worktree.path) } const gitOptions = localGitOptionsForTarget(target) + // Why: Git can't ignore a shared symlink under a directory-only rule, so tell + // status which untracked entries are Orca's own artifacts (issue #10451). + const sharedLinkPaths = target.repo ? getWorktreeSharedLinkPaths(target.repo) : [] + const sharedOptions = sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {} return options - ? getGitStatus(target.worktree.path, { ...options, ...gitOptions }) - : getGitStatus(target.worktree.path, gitOptions) + ? getGitStatus(target.worktree.path, { ...options, ...gitOptions, ...sharedOptions }) + : getGitStatus(target.worktree.path, { ...gitOptions, ...sharedOptions }) } async getRuntimeGitSubmoduleStatus( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 71d74396f8f..70463924d84 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -119,6 +119,7 @@ import { _resetTerminalViewAttributesForTest, setTerminalViewAttributes } from './terminal-view-attribute-store' +import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') @@ -129,6 +130,7 @@ const resolveLocalGitUsernameMock = vi.hoisted(() => vi.fn(async () => '')) vi.mock('../ipc/worktree-symlinks', () => ({ createWorktreeCopiedPaths: vi.fn(), createWorktreeLinkedPaths: vi.fn(), + createWorktreeSharedPaths: vi.fn(), findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock })) @@ -613,6 +615,7 @@ vi.mock('../git/git-username', async () => { function resetRuntimeTestMocks(): void { resetPlatform() + clearConfiguredWorktreeSharedDirectoriesCacheForTests() _resetTerminalViewAttributesForTest() advertisedUrlWatcher.clear() electronMocks.BrowserWindow.fromId.mockReset() @@ -36585,6 +36588,26 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes project shared links through the runtime removal preflight and cleanup', async () => { + const runtime = createWorktreeRemovalRuntime() + vi.mocked(loadHooks).mockReturnValue({ + scripts: {}, + worktree: { sharedDirectories: ['node_modules'] } + }) + findExistingWorktreeSymlinkPathsMock.mockResolvedValue(['node_modules']) + vi.mocked(removeWorktree).mockResolvedValue({}) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(findExistingWorktreeSymlinkPathsMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH, [ + 'node_modules' + ]) + expect(assertWorktreeCleanForRemoval).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false, { + ignoredUntrackedPaths: ['node_modules'] + }) + expect(removeWorktreeLinkedPathsMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH, ['node_modules']) + }) + it('does not remove a runtime worktree when watcher teardown cannot release it', async () => { const repo = { ...store.getRepos()[0], symlinkPaths: ['node_modules'] } const runtimeStore = { ...store, getRepos: () => [repo], getRepo: () => repo } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 14cf1eaf807..986b7c33f12 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -184,7 +184,20 @@ import type { TuiAgent, WorkspaceCreateTelemetrySource, WorkspaceSessionState, - DirEntry + DirEntry, + GitHubIssueUpdate, + GitHubPullRequestStateUpdate, + GitHubPRFile, + GitHubPRReviewCommentInput, + GitLabIssueUpdate, + GitLabMRInlineCommentInput, + GitLabProjectRef, + GitLabWorkItem, + ListWorkItemsResult, + MRListState, + PRRefreshOutcome, + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { @@ -237,7 +250,65 @@ import type { } from '../../shared/linear-agent-access' import { HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDesktopWindowStatus + type RuntimeDesktopWindowStatus, + type RuntimeGraphStatus, + type RuntimeRepoSearchRefs, + type RuntimeTerminalRead, + type RuntimeTerminalRename, + type RuntimeTerminalAgentStatus, + type RuntimeTerminalSend, + type RuntimeTerminalCreate, + type RuntimeTerminalPresentation, + type RuntimeTerminalSplit, + type RuntimeTerminalFocus, + type RuntimeTerminalClose, + type RuntimeTerminalListResult, + type RuntimeTerminalOrphanAdoptionRequest, + type RuntimeTerminalOrphanAdoptionResult, + type RuntimeWorktreeTerminalSleepResult, + type RuntimeTerminalResolvePane, + type RuntimeTerminalState, + type RuntimeStatus, + type RuntimeSyncWindowGraphResult, + type RuntimeTerminalWait, + type RuntimeTerminalWaitBlockedReason, + type RuntimeTerminalWaitCondition, + type RuntimeWorktreePsSummary, + type RuntimeWorktreeAgentRow, + type RuntimeWorktreeStatus, + type RuntimeSpeechModelSummary, + type RuntimeSpeechSetupState, + type RuntimeTerminalShow, + type RuntimeTerminalSummary, + type RuntimeTerminalVisualGroupNode, + type RuntimeTerminalVisualLayout, + type RuntimeTerminalVisualLayoutNode, + type RuntimeTerminalVisualPaneNode, + type RuntimeTerminalVisualTab, + type RuntimeSyncedLeaf, + type RuntimeSyncedTab, + type RuntimeMarkdownReadTabResult, + type RuntimeMarkdownSaveTabResult, + type RuntimeMobileSessionCreateTerminalResult, + type RuntimeMobileSessionClientTab, + type RuntimeMobileSessionTabCloseResult, + type RuntimeMobileSessionMarkdownTab, + type RuntimeMobileSessionTabMove, + type RuntimeMobileSessionTabMoveResult, + type RuntimeMobileSessionTabGroup, + type RuntimeMobileSessionSnapshotTab, + type RuntimeMobileSessionTerminalTab, + type RuntimeMobileSessionBrowserTab, + type RuntimeMobileSessionTabsRemovedResult, + type RuntimeMobileSessionTabsResult, + type RuntimeMobileSessionTabsSnapshot, + type RuntimeSessionTabCloseReason, + type RuntimeBrowserDriverState, + type RuntimeTerminalDriverState, + type RuntimeSyncWindowGraph, + type RuntimeWorktreeListResult, + type BrowserTabInfo, + type BrowserScreencastResult } from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, @@ -377,66 +448,6 @@ import { scanWorkspacePortProbes } from '../ports/workspace-port-ownership' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import type { - RuntimeGraphStatus, - RuntimeRepoSearchRefs, - RuntimeTerminalRead, - RuntimeTerminalRename, - RuntimeTerminalAgentStatus, - RuntimeTerminalSend, - RuntimeTerminalCreate, - RuntimeTerminalPresentation, - RuntimeTerminalSplit, - RuntimeTerminalFocus, - RuntimeTerminalClose, - RuntimeTerminalListResult, - RuntimeTerminalOrphanAdoptionRequest, - RuntimeTerminalOrphanAdoptionResult, - RuntimeWorktreeTerminalSleepResult, - RuntimeTerminalResolvePane, - RuntimeTerminalState, - RuntimeStatus, - RuntimeSyncWindowGraphResult, - RuntimeTerminalWait, - RuntimeTerminalWaitBlockedReason, - RuntimeTerminalWaitCondition, - RuntimeWorktreePsSummary, - RuntimeWorktreeAgentRow, - RuntimeWorktreeStatus, - RuntimeSpeechModelSummary, - RuntimeSpeechSetupState, - RuntimeTerminalShow, - RuntimeTerminalSummary, - RuntimeTerminalVisualGroupNode, - RuntimeTerminalVisualLayout, - RuntimeTerminalVisualLayoutNode, - RuntimeTerminalVisualPaneNode, - RuntimeTerminalVisualTab, - RuntimeSyncedLeaf, - RuntimeSyncedTab, - RuntimeMarkdownReadTabResult, - RuntimeMarkdownSaveTabResult, - RuntimeMobileSessionCreateTerminalResult, - RuntimeMobileSessionClientTab, - RuntimeMobileSessionTabCloseResult, - RuntimeMobileSessionMarkdownTab, - RuntimeMobileSessionTabMove, - RuntimeMobileSessionTabMoveResult, - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionSnapshotTab, - RuntimeMobileSessionTerminalTab, - RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionTabsRemovedResult, - RuntimeMobileSessionTabsResult, - RuntimeMobileSessionTabsSnapshot, - RuntimeSessionTabCloseReason, - RuntimeBrowserDriverState, - RuntimeTerminalDriverState, - RuntimeSyncWindowGraph, - RuntimeWorktreeListResult, - BrowserTabInfo, - BrowserScreencastResult -} from '../../shared/runtime-types' import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' @@ -474,7 +485,13 @@ import { deriveClientSessionTabSelection, projectClientSessionTabSelection } from './client-session-tab-selection' -import type { PtyProviderBufferSnapshot } from '../providers/types' +import type { + PtyProviderBufferSnapshot, + IFilesystemProvider, + IPtyProvider, + PtyProcessInfo, + PtyTransientFact +} from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { AgentTeamsTmuxCompatRequest, @@ -527,9 +544,9 @@ import { addPRReviewCommentReply, listLabels, listAssignableUsers, - type MainWorkItem + type MainWorkItem, + type GitHubPRBranchLookupOptions } from '../github/client' -import type { GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { fetchGitHubPullRequestHeadRef, @@ -579,19 +596,6 @@ import { type GitLabIssueListState } from '../gitlab/gitlab-preload-args' import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' -import type { - GitHubIssueUpdate, - GitHubPullRequestStateUpdate, - GitHubPRFile, - GitHubPRReviewCommentInput, - GitLabIssueUpdate, - GitLabMRInlineCommentInput, - GitLabProjectRef, - GitLabWorkItem, - ListWorkItemsResult, - MRListState, - PRRefreshOutcome -} from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { CreateHostedReviewInput, @@ -785,7 +789,7 @@ import { removeWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' -import { isENOENT } from '../ipc/filesystem-auth' +import { isENOENT, invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getDefaultTabCommandTrustContent, @@ -810,11 +814,16 @@ import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeCopiedPaths, createWorktreeLinkedPaths, + createWorktreeSharedPaths, findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../ipc/worktree-symlinks' import { formatWorktreeIncludeCopyWarning } from '../ipc/worktree-include-copy-budget' import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' +import { + getWorktreeSharedLinkPaths, + resolveWorktreeSharedDirectories +} from '../git/worktree-shared-directories' import { deleteWorktreeHistoryDir } from '../terminal-history' import { cleanupUnusedWorktreePushTargetRemote, @@ -862,7 +871,6 @@ import { UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { closeLocalWatcherForWorktreePath, @@ -895,12 +903,6 @@ import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' -import type { - IFilesystemProvider, - IPtyProvider, - PtyProcessInfo, - PtyTransientFact -} from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -923,7 +925,6 @@ import type { } from '../codex-accounts/service' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { RateLimitService } from '../rate-limits/service' -import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types' import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' @@ -19461,6 +19462,16 @@ export class OrcaRuntimeService { await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) } + // Why: project-level `orca.yaml` shared directories add to (never replace) the + // per-user setting, so a repo's shared dirs reach every teammate (issue #10451). + const sharedDirectories = await resolveWorktreeSharedDirectories( + repo.path, + localWorktreeGitOptions + ) + if (sharedDirectories.length > 0) { + await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + } + // Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics // (never symlink) so each worktree owns its files. Paths already linked above are skipped. const worktreeIncludePaths = await resolveWorktreeIncludePaths( @@ -21586,7 +21597,10 @@ export class OrcaRuntimeService { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } - const linkedPaths = repo.symlinkPaths ?? [] + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = getWorktreeSharedLinkPaths(repo) const ignoredLinkedPaths = force ? [] : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) @@ -26842,6 +26856,7 @@ export class OrcaRuntimeService { ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), + ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 2af865a0df5..7fcd5288f5d 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -367,7 +367,7 @@ export class Coordinator { terminals.push(created.handle) this.opts.onLog(`Created worker terminal ${created.handle}`) } catch (err) { - this.opts.onLog(`Failed to create terminal: ${err}`) + this.opts.onLog(`Failed to create terminal: ${String(err)}`) return } } @@ -383,7 +383,7 @@ export class Coordinator { try { await this.dispatchTask(task, targetHandle) } catch (err) { - this.opts.onLog(`Failed to dispatch task ${task.id}: ${err}`) + this.opts.onLog(`Failed to dispatch task ${task.id}: ${String(err)}`) } } } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 04fcb29fb55..dfcdc81f567 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -31,8 +31,10 @@ import type { FederationRelayItemRow } from './types' import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' import { parsePaneKey } from '../../../shared/stable-pane-id' import { OrchestrationError } from './orchestration-error' +import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -140,10 +142,10 @@ function exposeQuestionTimestamps(question: QuestionRow): QuestionRow { } } -export const LEGACY_RUN_ID = 'run_legacy_local' +export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state. -const SCHEMA_VERSION = 17 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair. +const SCHEMA_VERSION = 18 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -415,7 +417,12 @@ export class OrchestrationDb { // Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing). private migrate(): void { - const current = this.db.pragma('user_version', { simple: true }) as number + const storedVersion = this.db.pragma('user_version', { simple: true }) as number + const current = resolveOrchestrationMigrationStartVersion( + this.db, + storedVersion, + SCHEMA_VERSION + ) if (current >= SCHEMA_VERSION) { return } diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index de71c21ded5..54f13a477a4 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -77,6 +77,17 @@ describe('formatMessageBanner', () => { ) }) + it('marks legacy messages read-only without reply or acknowledgment affordances', () => { + const banner = formatMessageBanner( + makeMessage({ id: 'msg_legacy', run_id: 'run_legacy_local' }) + ) + + expect(banner).toContain('[LEGACY READ-ONLY]') + expect(banner).toContain('[Inspection only: reply and acknowledgment are unavailable.]') + expect(banner).not.toContain('[Reply:') + expect(banner).not.toContain('orchestration reply') + }) + it('ends with a separator line', () => { const banner = formatMessageBanner(makeMessage()) const lines = banner.split('\n') diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index a86f3c61edf..e7ca26558bf 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -1,21 +1,23 @@ import type { MessageRow } from './types' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' const BANNER_WIDTH = 60 const SEPARATOR = '─'.repeat(BANNER_WIDTH) -// Why: rich message banners help agents (and humans reading terminal output) -// quickly parse message metadata. Priority indicators surface urgent messages -// visually. The reply hint reduces friction for agent-to-agent responses -// (Section 4.8). export function formatMessageBanner(msg: MessageRow): string { const priorityTag = msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : '' + const legacyReadOnly = msg.run_id === ORCHESTRATION_LEGACY_RUN_ID + const authorityTag = legacyReadOnly ? ' [LEGACY READ-ONLY]' : '' const senderName = msg.from_handle.toUpperCase() - const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag} (${msg.type}) ────` + const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag}${authorityTag} (${msg.type}) ────` const lines: string[] = [header] lines.push(`Subject: ${msg.subject}`) + if (legacyReadOnly) { + lines.push('[Inspection only: reply and acknowledgment are unavailable.]') + } if (msg.body) { lines.push(msg.body) @@ -25,11 +27,12 @@ export function formatMessageBanner(msg: MessageRow): string { lines.push(`[Payload: ${msg.payload}]`) } - // Why: injected reply commands must retain the receiving pane's identity - // even when an older shell lacks Orca's terminal environment variables. - lines.push( - `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` - ) + if (!legacyReadOnly) { + // Why: older shells can lack Orca's terminal identity environment. + lines.push( + `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` + ) + } lines.push(SEPARATOR) return lines.join('\n') diff --git a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts new file mode 100644 index 00000000000..9939974b4e9 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts @@ -0,0 +1,67 @@ +import type Database from '../../sqlite/sync-database' + +const POST_V6_COLUMNS = [ + ['messages', 'run_id'], + ['tasks', 'run_id'], + ['dispatch_contexts', 'run_id'], + ['dispatch_contexts', 'capability_hash'], + ['dispatch_contexts', 'process_incarnation'], + ['dispatch_contexts', 'capability_revoked_at'], + ['decision_gates', 'run_id'], + ['question_threads', 'run_id'], + ['worker_dispatches', 'runtime_epoch'], + ['federated_dispatches', 'to_home_imported_sequence'], + ['remote_dispatch_attachments', 'to_worker_imported_sequence'], + ['remote_dispatch_attachments', 'protocol_version'], + ['federation_relay_items', 'dispatch_id'], + ['remote_questions', 'message_id'] +] as const + +const POST_V6_INDEXES = [ + 'idx_messages_run_sequence', + 'idx_tasks_run_status', + 'idx_dispatch_run_status', + 'idx_gates_run_status', + 'idx_runs_coordinator_pane', + 'idx_deliveries_one_outstanding', + 'idx_deliveries_run_created', + 'idx_questions_dispatch_status', + 'idx_federation_relay_pending', + 'idx_remote_questions_dispatch_status' +] as const + +function hasOrchestrationColumn(db: Database.Database, table: string, column: string): boolean { + const rows = db.pragma(`table_info(${table})`) as { name: string }[] + return rows.some((row) => row.name === column) +} + +function hasOrchestrationIndex(db: Database.Database, index: string): boolean { + return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?").get(index) +} + +function messagesAllowQuestions(db: Database.Database): boolean { + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'") + .get() as { sql: string } | undefined + return !!row && row.sql.includes("'question'") +} + +function hasCompletePostV6Schema(db: Database.Database): boolean { + return ( + POST_V6_COLUMNS.every(([table, column]) => hasOrchestrationColumn(db, table, column)) && + POST_V6_INDEXES.every((index) => hasOrchestrationIndex(db, index)) && + messagesAllowQuestions(db) + ) +} + +export function resolveOrchestrationMigrationStartVersion( + db: Database.Database, + storedVersion: number, + schemaVersion: number +): number { + if (storedVersion >= schemaVersion || hasCompletePostV6Schema(db)) { + return storedVersion + } + // Why: version-skewed pre-Run databases can claim the post-v6 range while retaining v6 tables. + return Math.min(storedVersion, 6) +} diff --git a/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts b/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts new file mode 100644 index 00000000000..f8a97aa1ed0 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { LEGACY_RUN_ID, OrchestrationDb } from './db' + +describe('OrchestrationDb version-skew migration', () => { + let db: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + function createLegacySchemaClaimingVersion17(): string { + tempDir = mkdtempSync(join(tmpdir(), 'orca-db-version-skew-')) + const dbPath = join(tempDir, 'orchestration.db') + const raw = new Database(dbPath) + raw.exec(` + CREATE TABLE messages ( + id TEXT NOT NULL, + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT, + sender_pane_key TEXT + ); + CREATE UNIQUE INDEX idx_messages_id ON messages(id); + CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_thread ON messages(thread_id); + + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + parent_id TEXT, + created_by_terminal_handle TEXT, + task_title TEXT, + display_name TEXT, + spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','ready','dispatched','completed','failed','blocked')), + deps TEXT NOT NULL DEFAULT '[]', + result TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + CREATE INDEX idx_tasks_status ON tasks(status); + CREATE INDEX idx_tasks_parent ON tasks(parent_id); + + CREATE TABLE dispatch_contexts ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + assignee_handle TEXT, + assignee_pane_key TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','dispatched','completed','failed','circuit_broken')), + failure_count INTEGER NOT NULL DEFAULT 0, + last_failure TEXT, + dispatched_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_heartbeat_at TEXT + ); + CREATE INDEX idx_dispatch_task ON dispatch_contexts(task_id); + CREATE INDEX idx_dispatch_status ON dispatch_contexts(status); + + CREATE TABLE decision_gates ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + question TEXT NOT NULL, + options TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','resolved','timeout')), + resolution TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT + ); + CREATE INDEX idx_gates_task ON decision_gates(task_id); + CREATE INDEX idx_gates_status ON decision_gates(status); + + CREATE TABLE coordinator_runs ( + id TEXT PRIMARY KEY, + spec TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle' + CHECK(status IN ('idle','running','completed','failed')), + coordinator_handle TEXT NOT NULL, + poll_interval_ms INTEGER NOT NULL DEFAULT 2000, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ); + + INSERT INTO messages ( + id, from_handle, to_handle, subject, body, type + ) VALUES ( + 'msg_legacy', 'term_worker', 'term_coord', 'retained message', 'done', 'status' + ); + INSERT INTO tasks ( + id, created_by_terminal_handle, task_title, display_name, spec, status + ) VALUES ( + 'task_legacy', 'term_coord', 'Legacy task', 'Legacy task', 'retained task', 'dispatched' + ); + INSERT INTO dispatch_contexts ( + id, task_id, assignee_handle, assignee_pane_key, status + ) VALUES ( + 'ctx_legacy', 'task_legacy', 'term_worker', 'tab_legacy:leaf_legacy', 'dispatched' + ); + INSERT INTO decision_gates ( + id, task_id, question + ) VALUES ( + 'gate_legacy', 'task_legacy', 'retained gate' + ); + `) + raw.pragma('user_version = 17') + raw.close() + return dbPath + } + + it('repairs retained v6 rows when the database already claims v17', () => { + const dbPath = createLegacySchemaClaimingVersion17() + db = new OrchestrationDb(dbPath) + + expect(db.getRun(LEGACY_RUN_ID)).toMatchObject({ legacy: 1 }) + expect(db.getMessageById('msg_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID }) + expect(db.getTask('task_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID }) + expect(db.getDispatchContextById('ctx_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID }) + expect(db.getGate('gate_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID }) + + const run = db.createRun({ + objective: 'verify repaired orchestration', + coordinatorHandle: 'term_coord_v2', + coordinatorPaneKey: 'tab_v2:leaf_coord' + }) + const task = db.createTask({ spec: 'reply with ack', runId: run.id }) + const dispatch = db.createDispatchContext(task.id, 'term_worker_v2', 'tab_v2:leaf_worker') + const question = db.createQuestion({ + runId: run.id, + dispatchId: dispatch.id, + askerHandle: 'term_worker_v2', + question: 'ack?' + }) + const delivery = db.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + expect(question.message.type).toBe('question') + expect(delivery?.messages.map((message) => message.id)).toContain(question.message.id) + + db.close() + db = undefined + db = new OrchestrationDb(dbPath) + expect(db.listTasks({ runId: LEGACY_RUN_ID }).map((row) => row.id)).toEqual(['task_legacy']) + expect(db.getRun(run.id)).toBeDefined() + expect(db.getQuestion(question.message.id)).toMatchObject({ status: 'pending' }) + }) +}) diff --git a/src/main/runtime/public-ssh-state.test.ts b/src/main/runtime/public-ssh-state.test.ts new file mode 100644 index 00000000000..1036b5ade0b --- /dev/null +++ b/src/main/runtime/public-ssh-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { SshConnectionState, SshProviderEpoch } from '../../shared/ssh-types' +import { getPublicSshState } from './public-ssh-state' + +describe('public SSH state', () => { + it('preserves the complete provider authority pair', () => { + const state: SshConnectionState = { + targetId: 'ssh-a', + status: 'error', + error: 'private detail', + reconnectAttempt: 0, + providerEpoch: 'provider-a' as SshProviderEpoch, + connectionGeneration: 3 + } + + expect(getPublicSshState(state)).toEqual({ + ...state, + error: 'SSH connection unavailable' + }) + }) +}) diff --git a/src/main/runtime/relay/relay-host-proof.test.ts b/src/main/runtime/relay/relay-host-proof.test.ts new file mode 100644 index 00000000000..39e47e322b2 --- /dev/null +++ b/src/main/runtime/relay/relay-host-proof.test.ts @@ -0,0 +1,225 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' +import { + answerRelayHostChallenge, + type RelayHostChallenge, + type RelayHostProofContext +} from './relay-host-proof' + +const encoder = new TextEncoder() +const HOST_PROOF_DOMAIN = 'orca-relay-host-proof/v1' +const CHALLENGE_DOMAIN = 'orca-relay-host-challenge/v1' +const CLOCK_SKEW_MS = 30_000 + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)) + let offset = 0 + for (const part of parts) { + output.set(part, offset) + offset += part.byteLength + } + return output +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function field(name: string, value: Uint8Array): Uint8Array { + const encodedName = encoder.encode(name) + return concat([uint32(encodedName.byteLength), encodedName, uint32(value.byteLength), value]) +} + +function text(value: string): Uint8Array { + return encoder.encode(value) +} + +function buildTranscript(input: { + origin: string + relayKey: Uint8Array + nonce: Uint8Array + challengeId: string + issuedAt: number + expiresAt: number + relayHostId: string + hostKey: Uint8Array + userId?: string + profileId?: string + organizationId?: string + assignmentEpoch?: number +}): Uint8Array { + return concat([ + field('protocol', text(HOST_PROOF_DOMAIN)), + field('version', new Uint8Array([1])), + field('relayOrigin', text(input.origin)), + field('relayEphemeralPublicKey', input.relayKey), + field('challengeNonce', input.nonce), + field('challengeId', text(input.challengeId)), + field('issuedAt', uint64(input.issuedAt)), + field('expiresAt', uint64(input.expiresAt)), + field('userId', text(input.userId ?? 'user-1')), + field('profileId', text(input.profileId ?? 'profile-1')), + field('organizationId', text(input.organizationId ?? 'org-1')), + field('relayHostId', text(input.relayHostId)), + field('hostPublicKey', input.hostKey), + field('assignmentEpoch', uint64(input.assignmentEpoch ?? 3)), + field('previousGeneration', new Uint8Array()), + field('resumeRequested', new Uint8Array([0])) + ]) +} + +function buildChallengeFixture(options: { + issuedAt: number + expiresAt: number + localNow: number +}): { + challenge: RelayHostChallenge + context: RelayHostProofContext + expectedProof: string +} { + const hostKeys = nacl.box.keyPair() + const relayKeys = nacl.box.keyPair() + const nonce = randomBytes(24) + const secret = randomBytes(32) + const origin = 'https://c2.relay.onorca.dev' + const relayHostId = 'host-abc123' + const challengeId = 'challenge-skew' + const transcript = buildTranscript({ + origin, + relayKey: relayKeys.publicKey, + nonce, + challengeId, + issuedAt: options.issuedAt, + expiresAt: options.expiresAt, + relayHostId, + hostKey: hostKeys.publicKey + }) + const plaintext = concat([ + text(`${CHALLENGE_DOMAIN}\0`), + uint32(transcript.byteLength), + transcript, + secret + ]) + const challenge: RelayHostChallenge = { + challengeId, + relayEphemeralPublicKeyB64: Buffer.from(relayKeys.publicKey).toString('base64'), + nonceB64: nonce.toString('base64'), + ciphertextB64: Buffer.from( + nacl.box(plaintext, nonce, hostKeys.publicKey, relayKeys.secretKey) + ).toString('base64'), + expiresAt: options.expiresAt + } + const context: RelayHostProofContext = { + relayOrigin: origin, + userId: 'user-1', + profileId: 'profile-1', + organizationId: 'org-1', + relayHostId, + hostPublicKey: hostKeys.publicKey, + hostSecretKey: hostKeys.secretKey, + assignmentEpoch: 3, + resumeRequested: false, + now: () => options.localNow + } + const expectedProof = createHmac('sha256', secret) + .update(text(`${HOST_PROOF_DOMAIN}\0ack\0`)) + .update(transcript) + .digest('base64') + return { challenge, context, expectedProof } +} + +describe('answerRelayHostChallenge clock skew (#10401)', () => { + it('accepts a challenge when local clock is a few seconds behind (issuedAt in the near future)', () => { + const serverNow = 1_700_000_000_000 + const skewBehindMs = 4_400 + const localNow = serverNow - skewBehindMs + const issuedAt = serverNow + const expiresAt = issuedAt + 10_000 + const { challenge, context, expectedProof } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow + }) + + expect(answerRelayHostChallenge(challenge, context)).toBe(expectedProof) + }) + + it('accepts a challenge when local clock is a few seconds ahead of expiresAt', () => { + const serverNow = 1_700_000_000_000 + const issuedAt = serverNow + const expiresAt = issuedAt + 10_000 + const localNow = expiresAt + 4_400 + const { challenge, context, expectedProof } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow + }) + + expect(answerRelayHostChallenge(challenge, context)).toBe(expectedProof) + }) + + it('accepts challenges at the clock-skew boundaries', () => { + const issuedAt = 1_700_000_000_000 + const expiresAt = issuedAt + 10_000 + for (const localNow of [issuedAt - CLOCK_SKEW_MS, expiresAt + CLOCK_SKEW_MS]) { + const { challenge, context, expectedProof } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow + }) + expect(answerRelayHostChallenge(challenge, context)).toBe(expectedProof) + } + }) + + it('still rejects challenges outside the allowed skew window', () => { + const serverNow = 1_700_000_000_000 + const issuedAt = serverNow + const expiresAt = issuedAt + 10_000 + const localNowTooBehind = issuedAt - CLOCK_SKEW_MS - 1 + const behind = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: localNowTooBehind + }) + expect(answerRelayHostChallenge(behind.challenge, behind.context)).toBeNull() + + const localNowTooAhead = expiresAt + CLOCK_SKEW_MS + 1 + const ahead = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: localNowTooAhead + }) + expect(answerRelayHostChallenge(ahead.challenge, ahead.context)).toBeNull() + }) + + it('still rejects an oversized server challenge window', () => { + const issuedAt = 1_700_000_000_000 + const expiresAt = issuedAt + 10_001 + const { challenge, context } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: issuedAt + }) + expect(answerRelayHostChallenge(challenge, context)).toBeNull() + }) + + it('rejects a challenge that expires before it is issued', () => { + const issuedAt = 1_700_000_000_000 + const expiresAt = issuedAt - 1 + const { challenge, context } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: issuedAt + }) + expect(answerRelayHostChallenge(challenge, context)).toBeNull() + }) +}) diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts index 94db5b2f1b2..da17f8a1576 100644 --- a/src/main/runtime/relay/relay-host-proof.ts +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -3,6 +3,9 @@ import nacl from 'tweetnacl' const HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-relay-host-proof/v1' const HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-relay-host-challenge/v1' +// Covers routine NTP drift without extending the signed challenge window. +const RELAY_HOST_PROOF_CLOCK_SKEW_MS = 30_000 +const MAX_HOST_PROOF_CHALLENGE_WINDOW_MS = 10_000 const textEncoder = new TextEncoder() const textDecoder = new TextDecoder() @@ -102,9 +105,10 @@ function validateTranscript( context.previousGeneration === undefined ? new Uint8Array() : uint64(context.previousGeneration) return ( issuedAt !== null && - issuedAt <= now && - now <= challenge.expiresAt && - challenge.expiresAt - issuedAt <= 10_000 && + issuedAt - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= now && + now - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= challenge.expiresAt && + issuedAt <= challenge.expiresAt && + challenge.expiresAt - issuedAt <= MAX_HOST_PROOF_CHALLENGE_WINDOW_MS && expiresAt === challenge.expiresAt && equal(fields.get('protocol'), textEncoder.encode(HOST_PROOF_TRANSCRIPT_DOMAIN)) && equal(fields.get('version'), new Uint8Array([1])) && diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index b3aeb044daf..f234dce1536 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -6,8 +6,10 @@ import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' -import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../../shared/agent-session-host-authority' -import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../../shared/agent-session-host-authority' +import { + AGENT_SESSION_RPC_ERROR_CODES, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../../shared/agent-session-host-authority' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index cd6c9f00cca..e494148ef7f 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -3,8 +3,7 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { parseExecutionHostId } from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. // Each scopePath is a host-local match prefix (validated/capped, never used for diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 6b8862b1ec2..50f199abcdf 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -188,6 +188,7 @@ export const UiUpdate = z rightSidebarExplorerView: z.enum(['files', 'search']).optional(), rightSidebarWidth: z.number().finite().optional(), markdownTocPanelWidth: z.number().finite().optional(), + combinedDiffFileTreeWidth: z.number().finite().optional(), groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), showWorkspaceLineage: z.boolean().optional(), sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), diff --git a/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts b/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts index f612f27dc0d..daf01b27ef7 100644 --- a/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts +++ b/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts @@ -17,9 +17,9 @@ export async function runFileWatchStream(args: { let settled = false let setupFailed = false let watchReady = false - let unwatch: (() => void) | null = null + let unwatch: (() => Promise) | null = null let terminalError: Error | null = null - let setupPromise: Promise<() => void> | null = null + let setupPromise: Promise<() => Promise> | null = null let cleanupPromise: Promise | null = null let logicalCleanupStarted = false let endEmitted = false diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 51758dbc803..1e94b95176e 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,6 +1,9 @@ import { z } from 'zod' -import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' -import type { AgentType } from '../../../../shared/native-chat-types' +import type { + NativeChatBlock, + NativeChatMessage, + AgentType +} from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index ce76d7b611f..87b482dc304 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -49,6 +49,54 @@ describe('orchestration migration behavior', () => { expect(db.getTask(task.id)?.status).toBe('ready') }) + it('formats legacy terminal inspection as read-only without consuming mail', async () => { + const { db, runtime } = createRuntime() + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coord', + subject: 'still working', + body: 'Tests are running.' + }) + const check = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.check')! + + const inspected = (await check.handler( + check.params!.parse({ terminal: 'term_coord', peek: true, format: true }), + { runtime } + )) as { count: number; formatted: string } + + expect(inspected.count).toBe(1) + expect(inspected.formatted).toContain('[LEGACY READ-ONLY]') + expect(inspected.formatted).toContain('Tests are running.') + expect(inspected.formatted).not.toContain('[Reply:') + expect(db.getMessageById(message.id)?.read).toBe(0) + }) + + it('rejects replies to legacy mail without marking or inserting rows', async () => { + const { db, runtime } = createRuntime() + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coord', + subject: 'legacy question' + }) + const reply = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.reply')! + + await expect( + reply.handler( + reply.params!.parse({ + id: message.id, + body: 'replacement started', + from: 'term_coord' + }), + { runtime } + ) + ).rejects.toMatchObject({ + code: 'legacy_read_only', + data: { effectsApplied: false } + }) + expect(db.getMessageById(message.id)?.read).toBe(0) + expect(db.getInbox(100)).toHaveLength(1) + }) + it('rejects a pre-contract worker_done before message or lifecycle mutation', async () => { const { db, runtime } = createRuntime() const run = db.createRun({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 62ed7816487..4605c9879a8 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -16,6 +16,8 @@ export type WorkerEffect = { hookFound?: boolean startupPolicy?: string terminalId?: string + surface?: 'visible' | 'background' + warning?: string } export type WorkerSetupReceipt = { @@ -34,6 +36,28 @@ export type WorkerSetupReceipt = { | 'not_applicable' } +export async function createExistingWorktreeWorkerTerminal(args: { + runtime: OrcaRuntimeService + worktreeId: string + agent: TuiAgent + taskId: string + effects: WorkerEffect[] +}): Promise<{ handle: string; warning?: string }> { + const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, { + command: args.agent, + title: `worker-${args.taskId}` + }) + args.effects.push({ + kind: 'terminal', + role: 'agent', + action: 'created', + id: terminal.handle, + surface: terminal.surface, + warning: terminal.warning + }) + return { handle: terminal.handle, warning: terminal.warning } +} + export function applyWaitForSetupOutcome( receipt: WorkerSetupReceipt, effects: WorkerEffect[], diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 0b2b19a18e0..9525de9d240 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -7,6 +7,7 @@ import { startFederatedWorker } from './orchestration-federated-worker-start' import { assertOrchestrationWorktreeCreationSupported } from './orchestration-folder-worktree-placement' import { WorkerStartParams } from './orchestration-worker-start-schema' import { + createExistingWorktreeWorkerTerminal, createWorkerWorktree, monitorWorkerSetup, type WorkerEffect, @@ -151,6 +152,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ ) } let terminalHandle = params.terminal + let terminalRevealWarning: string | undefined let failedStage = 'terminal_create' let setupReceipt: WorkerSetupReceipt = { requested: 'not_applicable', @@ -183,18 +185,15 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ worktreeId: resolvedWorktree!.id, effects }) - const terminal = await runtime.createTerminal(`id:${resolvedWorktree!.id}`, { - command: agent, - title: `worker-${task.id}`, - presentation: 'background' + const terminal = await createExistingWorktreeWorkerTerminal({ + runtime, + worktreeId: resolvedWorktree!.id, + agent: agent as TuiAgent, + taskId: task.id, + effects }) terminalHandle = terminal.handle - effects.push({ - kind: 'terminal', - role: 'agent', - action: 'created', - id: terminal.handle - }) + terminalRevealWarning = terminal.warning } else { effects.push({ kind: 'terminal', @@ -287,7 +286,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ setup: setupReceipt, timeoutMs: params.timeoutMs ?? 60_000, effects, - residualResources: [] + residualResources: [], + ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) } } catch (error) { return failWorkerStartWithReceipt({ diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 899a94fa190..d8d53cacba8 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -322,6 +322,32 @@ describe('orchestration RPC methods', () => { }) }) + it('routes Dispatch mail by stable pane identity after worker handle remint', async () => { + setup() + const task = db.createTask({ spec: 'controlled worker after restart' }) + const dispatch = db.createDispatchContext( + task.id, + 'term_worker_before', + 'tab_worker:leaf_worker' + ) + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Continue after restart', + runId: activeRunId + }) + + const workerCheck = (await call('orchestration.check', { + terminal: 'term_worker_after', + terminalPaneKey: 'tab_worker:leaf_worker' + })) as { dispatchId: string; messages: { subject: string }[] } + + expect(workerCheck).toMatchObject({ + dispatchId: dispatch.id, + messages: [{ subject: 'Continue after restart' }] + }) + }) + it('rejects hidden task-recipient retargeting', async () => { setup() await expect( @@ -1043,6 +1069,56 @@ describe('orchestration RPC methods', () => { expect(inboxB.messages.map((message) => message.subject)).toEqual(['B only']) }) + it('uses the stable pane identity when the coordinator handle was reminted', async () => { + setup() + db.insertMessage({ + from: 'term_worker', + to: `run:${activeRunId}`, + subject: 'Completed after restart', + runId: activeRunId + }) + + const result = (await call('orchestration.check', { + terminal: 'term_stale_coord', + terminalPaneKey: coordinatorPaneKey + })) as { runId: string; messages: { subject: string }[] } + + expect(result).toMatchObject({ + runId: activeRunId, + messages: [{ subject: 'Completed after restart' }] + }) + }) + + it('keeps a live handle authoritative over mismatched pane metadata', async () => { + setup() + const foreignRun = db.createRun({ + objective: 'Foreign run', + coordinatorHandle: 'term_foreign', + coordinatorPaneKey: 'tab_foreign:leaf_foreign' + }) + db.insertMessage({ + from: 'term_worker', + to: `run:${activeRunId}`, + subject: 'Coordinator only', + runId: activeRunId + }) + db.insertMessage({ + from: 'term_foreign_worker', + to: `run:${foreignRun.id}`, + subject: 'Foreign only', + runId: foreignRun.id + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + terminalPaneKey: 'tab_foreign:leaf_foreign', + all: true + })) as { runId: string; messages: { subject: string }[] } + + expect(result.runId).toBe(activeRunId) + expect(result.messages.map((message) => message.subject)).toEqual(['Coordinator only']) + }) + it('returns formatted output with --format', async () => { setup() db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) @@ -1529,17 +1605,25 @@ describe('orchestration RPC methods', () => { describe('orchestration.reply', () => { it('replies to a message', async () => { setup() - const original = db.insertMessage({ from: 'a', to: 'b', subject: 'question' }) + const original = db.insertMessage({ + from: 'a', + to: 'b', + subject: 'question', + runId: activeRunId + }) const result = (await call('orchestration.reply', { id: original.id, body: 'answer', from: 'b' - })) as { message: { to_handle: string; subject: string; thread_id: string } } + })) as { + message: { to_handle: string; subject: string; thread_id: string; run_id: string } + } expect(result.message.to_handle).toBe('a') expect(result.message.subject).toBe('Re: question') expect(result.message.thread_id).toBe(original.id) + expect(result.message.run_id).toBe(activeRunId) }) it('throws on nonexistent message', async () => { @@ -2063,12 +2147,52 @@ describe('orchestration RPC methods', () => { ) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready') + expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', { + command: 'codex', + title: `worker-${task.id}` + }) expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( 'term_worker', expect.stringContaining('--dispatch-capability dcap_') ) }) + it('surfaces a worker terminal reveal failure without discarding the live worker', async () => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.createTerminal).mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker', + surface: 'background', + warning: 'Terminal term_worker is running but could not be revealed.' + }) + const task = db.createTask({ spec: 'keep working if reveal fails' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { + state: string + warning?: string + effects: { kind: string; surface?: string; warning?: string }[] + } + + expect(result).toMatchObject({ + state: 'ready', + warning: 'Terminal term_worker is running but could not be revealed.' + }) + expect(result.effects).toContainEqual( + expect.objectContaining({ + kind: 'terminal', + surface: 'background', + warning: 'Terminal term_worker is running but could not be revealed.' + }) + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalled() + }) + it('starts a fresh agent in an exact existing worktree without replaying setup', async () => { setup() mockCurrentWorkerStart() diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 128520f4c33..2b7d5290a6f 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -9,7 +9,10 @@ import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' -import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' +import { + ORCHESTRATION_LEGACY_RUN_ID, + orchestrationSkillRecoveryData +} from '../../../../shared/orchestration-rpc-contract' import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' @@ -94,6 +97,7 @@ const SendParams = z const CheckParams = z .object({ terminal: OptionalString, + terminalPaneKey: OptionalString, unread: OptionalBoolean, peek: OptionalBoolean, // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). @@ -235,6 +239,7 @@ function resolveRunScope( params: { runId?: string callerTerminalHandle?: string + callerPaneKey?: string requireCurrentConsumer: boolean } ): RunRow { @@ -254,7 +259,7 @@ function resolveRunScope( orchestrationSkillRecoveryData() ) } - const paneKey = runtime.getTerminalPaneKey(params.callerTerminalHandle) + const paneKey = params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle) if (!paneKey) { throw new OrchestrationError( 'stable_pane_required', @@ -633,12 +638,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const handle = params.terminal ?? 'unknown' const typeFilter = parseMessageTypes(params.types) - const paneKey = runtime.getTerminalPaneKey(handle) + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined if (params.run || boundRun) { const run = resolveRunScope(runtime, { runId: params.run, callerTerminalHandle: handle, + callerPaneKey: paneKey ?? undefined, requireCurrentConsumer: true }) const generation = run.consumer_generation @@ -777,7 +784,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ remoteAttachment && !db.isRemoteAttachmentProcessCurrent({ dispatchId: remoteAttachment.dispatch_id, - paneKey, + paneKey: paneKey ?? null, processIncarnation: runtime.getTerminalProcessIncarnation(handle) }) ) { @@ -899,6 +906,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!original) { throw new Error(`Message not found: ${params.id}`) } + if (original.run_id === ORCHESTRATION_LEGACY_RUN_ID) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; no reply was applied.', + { effectsApplied: false } + ) + } const question = db.getQuestion(params.id) if (question) { @@ -943,7 +957,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ to: original.from_handle, subject: `Re: ${original.subject}`, body: params.body, - threadId: original.thread_id ?? original.id + threadId: original.thread_id ?? original.id, + runId: original.run_id }) runtime.notifyMessageArrived(original.from_handle, reply.type) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index f43f7cb93fa..3de93dcfe0f 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -7,6 +7,8 @@ import { import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' import { + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, + exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks, type TerminalOutputFrameChunk, type TerminalOutputMeta @@ -23,6 +25,19 @@ function legacyByteLengthExceeds(data: string, maxBytes: number): boolean { return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit } +function expectGateEquivalent(data: string, label: string): void { + expect({ label, result: exceedsTerminalStreamChunkBytes(data) }).toEqual({ + label, + result: legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES) + }) +} + +function makeExactByteLength(unit: string, byteLength: number): string { + const unitBytes = Buffer.byteLength(unit, 'utf8') + const repeats = Math.floor(byteLength / unitBytes) + return unit.repeat(repeats) + 'a'.repeat(byteLength - repeats * unitBytes) +} + function* legacyIterateTerminalOutputFrameChunks( data: string, meta?: TerminalOutputMeta @@ -216,6 +231,50 @@ function randomText(random: () => number, parts: number): string { } describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => { + it('matches the legacy gate at the byte cap ±3 for every UTF-8 shape', () => { + for (const [label, unit] of [ + ['ascii', 'a'], + ['two-byte', '\u00e9'], + ['three-byte', '\u20ac'], + ['astral', SURROGATE_PAIR], + ['lone-high', LONE_HIGH], + ['lone-low', LONE_LOW], + ['reversed-surrogates', `${LONE_LOW}${LONE_HIGH}a`] + ] as const) { + for (let delta = -3; delta <= 3; delta += 1) { + const byteLength = TERMINAL_STREAM_CHUNK_BYTES + delta + const data = makeExactByteLength(unit, byteLength) + expect(Buffer.byteLength(data, 'utf8')).toBe(byteLength) + expectGateEquivalent(data, `${label} delta=${delta}`) + } + } + }) + + it('matches when probe boundaries bisect or surround surrogate pairs', () => { + const probe = TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS + for (const offset of [-2, -1, 0, 1, 2]) { + const pairStart = probe + offset + const prefix = 'a'.repeat(pairStart) + const suffix = '\u20ac'.repeat(12_000) + for (const middle of [SURROGATE_PAIR, LONE_HIGH, LONE_LOW, LONE_LOW + LONE_HIGH]) { + const data = prefix + middle + suffix + expectGateEquivalent(data, `probe offset=${offset} middle=${escapeUnits(middle)}`) + expectEquivalent(data, undefined, `probe frames offset=${offset}`) + } + } + }) + + it('stops correctly when late wide text crosses the cap', () => { + const asciiPrefix = 'a'.repeat(16_000) + for (const wide of ['\u00e9', '\u20ac', SURROGATE_PAIR, LONE_HIGH]) { + for (const wideParts of [8_000, 12_000, 16_000]) { + const data = asciiPrefix + wide.repeat(wideParts) + expectGateEquivalent(data, `late-wide ${escapeUnits(wide)} parts=${wideParts}`) + expectEquivalent(data, undefined, `late-wide frames ${escapeUnits(wide)}`) + } + } + }) + it('matches on the small/no-chunking sizes', () => { for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) { sweepAll('a'.repeat(size), `ascii ${size}`) @@ -416,7 +475,7 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) } - }) + }, 30_000) it('keeps every emitted frame within the wire cap and reassembles to the input', () => { const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}` diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks.ts b/src/main/runtime/rpc/terminal-output-frame-chunks.ts index f040934a565..167b6c20494 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks.ts @@ -4,6 +4,7 @@ import { encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' +import { terminalStreamByteLength } from './terminal-stream-byte-length' export type TerminalOutputMeta = { seq?: number @@ -18,12 +19,31 @@ export type TerminalOutputFrameChunk = { opcode?: TerminalStreamOpcode } -// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is. -function exceedsTerminalStreamChunkBytes(data: string): boolean { - return ( - data.length > TERMINAL_STREAM_CHUNK_BYTES || - Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES - ) +export const TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS = 8 * 1024 +const MAX_UTF8_BYTES_PER_CODE_UNIT = 3 + +export function exceedsTerminalStreamChunkBytes(data: string): boolean { + if (data.length > TERMINAL_STREAM_CHUNK_BYTES) { + return true + } + if (data.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= TERMINAL_STREAM_CHUNK_BYTES) { + return false + } + let byteLength = 0 + for (let start = 0; start < data.length; ) { + let end = Math.min(start + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, data.length) + const high = data.charCodeAt(end - 1) + const low = data.charCodeAt(end) + if (end < data.length && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) { + end -= 1 + } + byteLength += terminalStreamByteLength(data.slice(start, end)) + if (byteLength > TERMINAL_STREAM_CHUNK_BYTES) { + return true + } + start = end + } + return false } export function* iterateTerminalOutputFrameChunks( diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 87d984b6f02..308c8ae2010 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -50,7 +50,7 @@ async function sendRequest( let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk const newlineIndex = buffer.indexOf('\n') if (newlineIndex === -1) { @@ -4061,7 +4061,7 @@ describe('OrcaRuntimeRpcServer', () => { let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk const newlineIndex = buffer.indexOf('\n') if (newlineIndex === -1) { diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts index cd826298628..ad1517c78a9 100644 --- a/src/main/skills/skill-freshness-eligibility.test.ts +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -5,6 +5,12 @@ import { } from '../../shared/skill-freshness' import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' +const globallyUpdatableNames = new Set(['computer-use', 'orca-cli', 'orchestration']) + +function eligible(installations: SkillFreshnessInstallation[]): string[] { + return eligibleSkillUpdateNames(installations, globallyUpdatableNames) +} + function placement( name: string, overrides: Partial = {} @@ -35,7 +41,7 @@ function placement( describe('skill freshness name-scoped update eligibility', () => { it('offers a name when at least one supported placement is outdated and all are official', () => { expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli'), placement('orca-cli', { id: 'orca-cli-claude', @@ -62,7 +68,7 @@ describe('skill freshness name-scoped update eligibility', () => { // update over one refuses work the command could do to a copy that is never at // stake. The canonical copy converges and the outlier is reported separately. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli'), placement('orca-cli', { id: `outlier-${status}-${topology}`, status, topology }) ]) @@ -76,7 +82,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: this is the placement the command writes to, so overwriting it is the // real data-loss case the rail exists to avoid. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli', { id: 'blocked-canonical', status }), placement('orca-cli', { id: 'orca-cli-claude', @@ -93,7 +99,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: a duplicate no longer omits the whole name — the canonical copy converges // and the duplicate row is flagged as maybe-not-reached rather than blocking. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli'), placement('orca-cli', { id: 'orca-cli-gemini', @@ -112,7 +118,7 @@ describe('skill freshness name-scoped update eligibility', () => { // name here advertises an update the command reports as already up to date, so the // badge could never clear; the dialog explains the duplicate as skipped instead. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orchestration', { status: 'current' }), placement('orchestration', { id: 'orchestration-factory', @@ -131,7 +137,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: with no canonical or alias to anchor `--global`, the command has no // reliable target, so a duplicate-only skill stays unoffered. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli', { rootId: 'home-gemini', unresolvedPath: '/home/.gemini/skills/orca-cli', @@ -147,7 +153,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: a project copy is never written by `--global`, so it does not speak for // the global one — while a name whose convergent copy is current stays unoffered. expect( - eligibleSkillUpdateNames([ + eligible([ placement('computer-use', { status: 'current' }), placement('orchestration'), placement('orchestration', { @@ -159,6 +165,10 @@ describe('skill freshness name-scoped update eligibility', () => { ).toEqual(['orchestration']) }) + it('does not offer an official canonical copy missing from the updater lock (#10791)', () => { + expect(eligibleSkillUpdateNames([placement('orca-cli')], new Set())).toEqual([]) + }) + it('builds only an explicit, deterministic global command', () => { expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe( 'npx skills update orca-cli orchestration --global' diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts index 76e21611693..52517132e1c 100644 --- a/src/main/skills/skill-freshness-eligibility.ts +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -13,9 +13,12 @@ import { * do, or refuse work it could, over a copy that is never at stake either way. A * blocked *convergent* copy still withholds it, because that is the placement the * command would write to and overwriting it is the real data-loss case. + * `globallyUpdatableNames` comes from the updater's lock; an unregistered copied + * bundle is installed but `skills update` cannot identify its source. */ export function eligibleSkillUpdateNames( - installations: readonly SkillFreshnessInstallation[] + installations: readonly SkillFreshnessInstallation[], + globallyUpdatableNames: ReadonlySet ): string[] { const byName = new Map() for (const installation of installations) { @@ -26,6 +29,9 @@ export function eligibleSkillUpdateNames( const eligible: string[] = [] for (const [, entries] of byName) { + if (!globallyUpdatableNames.has(entries[0].name)) { + continue + } const convergent = entries.filter((entry) => SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) ) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index c04616abb3a..cdf60c6420a 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -58,6 +58,21 @@ async function fixture() { ...snapshots[1] } await Promise.all([ + mkdir(join(homeDir, '.agents'), { recursive: true }).then(() => + writeFile( + join(homeDir, '.agents', '.skill-lock.json'), + `${JSON.stringify({ + version: 3, + skills: { + 'orca-cli': { + skillFolderHash: 'tracked-old-hash', + skillPath: 'skills/orca-cli/SKILL.md', + source: 'stablyai/orca' + } + } + })}\n` + ) + ), writeFile( join(skillResourceRoot, 'current-manifest.json'), `${JSON.stringify({ schemaVersion: 2, skills: [current] }, null, 2)}\n` @@ -121,6 +136,26 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) }) + it('does not offer an older copied bundle the external updater has never registered (#10791)', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + await rm(join(test.homeDir, '.agents', '.skill-lock.json')) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot, + stateHome: null + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'outdated' + }) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + it('labels newer known and unrecognized bytes honestly without calling them modified', async () => { const test = await fixture() await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index 97527906d1f..41467b11e86 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -17,6 +17,8 @@ import { type CandidateLstat } from './skill-freshness-placement-observation' import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' +import { convergableSkillNames } from './skill-update-convergence' +import { readGloballyUpdatableSkillLocks } from './skill-update-registration' export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 @@ -39,8 +41,12 @@ export async function inventorySkillFreshness(args: { repos?: Repo[] resourceRoot?: string candidateLstat?: CandidateLstat + stateHome?: string | null }): Promise { - const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) + const [artifacts, globalSkillLocks] = await Promise.all([ + loadSkillBundleArtifacts(args.resourceRoot), + readGloballyUpdatableSkillLocks({ homeDir: args.homeDir, stateHome: args.stateHome }) + ]) const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) const discoveryArgs = { homeDir: args.homeDir, @@ -163,7 +169,10 @@ export async function inventorySkillFreshness(args: { return { schemaVersion: 1, installations, - eligibleUpdateNames: eligibleSkillUpdateNames(installations), + eligibleUpdateNames: eligibleSkillUpdateNames( + installations, + convergableSkillNames(installations, globalSkillLocks, artifacts.knownSnapshots) + ), scanIssues, scannedAt: Date.now() } diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts index 4218a4cc316..87911de4823 100644 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -88,7 +88,8 @@ export async function observeSkillFreshnessInstallation(args: { status: 'inaccessible', installedReleaseRevision: null, installedAppVersion: null, - observedPackageDigest: null + observedPackageDigest: null, + observedGitTreeSha: null } } @@ -115,7 +116,8 @@ export async function observeSkillFreshnessInstallation(args: { : (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? null) : null, - observedPackageDigest: observed.observedDigest + observedPackageDigest: observed.observedDigest, + observedGitTreeSha: observed.observedGitTreeSha } } catch (error) { return { @@ -124,6 +126,7 @@ export async function observeSkillFreshnessInstallation(args: { installedReleaseRevision: null, installedAppVersion: null, observedPackageDigest: null, + observedGitTreeSha: null, errorCategory: errorCategory(error, 'skill-package-read-failed') } } diff --git a/src/main/skills/skill-git-tree-identity.test.ts b/src/main/skills/skill-git-tree-identity.test.ts new file mode 100644 index 00000000000..1761686e8db --- /dev/null +++ b/src/main/skills/skill-git-tree-identity.test.ts @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { devNull, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { SkillBundleManifest } from '../../shared/skill-freshness' +import { observeSkillPackage } from './skill-package-identity' + +const REPO_ROOT = resolve(__dirname, '..', '..', '..') + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +describe('observed skill git tree identity', () => { + // The decisive check: the runtime port must reproduce the generator's tree + // sha bit-for-bit, or the lock comparison never matches and the verdict fix + // is a silent no-op. The manifest's gitTreeSha values are generated from + // these same working-tree bytes. + it('matches the gitTreeSha the bundled manifest records for every shipped skill', async () => { + const manifest = JSON.parse( + await readFile(join(REPO_ROOT, 'resources', 'skills', 'current-manifest.json'), 'utf8') + ) as SkillBundleManifest + expect(manifest.skills.length).toBeGreaterThan(0) + for (const skill of manifest.skills) { + const observed = await observeSkillPackage(join(REPO_ROOT, 'skills', skill.name)) + expect(observed.observedGitTreeSha, skill.name).toBe(skill.gitTreeSha) + } + }) + + // Shipped skills are flat today, so this covers what they do not: nested + // directories, an executable, binary bytes, and the `z.txt` vs `z/` edge where + // git's slash-append ordering diverges from a plain name sort. + it('matches git write-tree over nesting, executables and the directory sort edge', async () => { + const base = await mkdtemp(join(tmpdir(), 'orca-skill-tree-sha-')) + temporaryDirectories.push(base) + const work = join(base, 'work') + const repoShell = join(base, 'repo') + await mkdir(join(work, 'z'), { recursive: true }) + await mkdir(join(work, 'nested', 'deep'), { recursive: true }) + await mkdir(repoShell, { recursive: true }) + await writeFile(join(work, 'SKILL.md'), '---\nname: fixture\n---\n') + await writeFile(join(work, 'z.txt'), 'sorts before the z directory\n') + await writeFile(join(work, 'z', 'inner.md'), 'directory entry\n') + await writeFile(join(work, 'nested', 'deep', 'tool.sh'), '#!/bin/sh\nexit 0\n') + await chmod(join(work, 'nested', 'deep', 'tool.sh'), 0o755) + await writeFile(join(work, 'blob.bin'), Buffer.from([0, 1, 2, 253, 254, 255])) + + const env = { ...process.env, GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_SYSTEM: devNull } + execFileSync('git', ['init', '--quiet', repoShell], { env }) + const gitDirArgs = ['--git-dir', join(repoShell, '.git'), '--work-tree', work] + execFileSync('git', [...gitDirArgs, '-c', 'core.autocrlf=false', 'add', '-A'], { + env, + cwd: work + }) + const expected = execFileSync('git', [...gitDirArgs, 'write-tree'], { + env, + cwd: work, + encoding: 'utf8' + }).trim() + + const observed = await observeSkillPackage(work) + expect(observed.observedGitTreeSha).toBe(expected) + }) +}) diff --git a/src/main/skills/skill-git-tree-identity.ts b/src/main/skills/skill-git-tree-identity.ts new file mode 100644 index 00000000000..79a017b8f6f --- /dev/null +++ b/src/main/skills/skill-git-tree-identity.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto' + +/** + * Git object identity for an observed skill package. + * + * The updater's lock records the git tree sha of the skill folder it installed, + * so hashing the observed bytes the same way lets the post-run verdict recognise + * content the bundled registry has never seen. The algorithm is a port of + * `gitTreeSha` in config/scripts/generate-skill-bundle-manifest.mjs and must + * match it exactly — a near-miss never matches anything and silently disables + * the verdict's lock check. + * + * Hashes are over RAW bytes deliberately: a CRLF materialization (Windows + * checkout) hashes differently from the source tree, so it fails closed to the + * pre-lock-check behavior rather than misidentifying content. + */ + +export type SkillGitTreeFileEntry = { + /** Slash-separated path relative to the package root. */ + path: string + executable: boolean + blobSha: Buffer +} + +type SkillGitTreeDirectory = { + directories: Map + files: { filename: string; executable: boolean; blobSha: Buffer }[] +} + +function gitObjectSha(kind: 'blob' | 'tree', bytes: Buffer): Buffer { + return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() +} + +export function gitBlobSha(bytes: Buffer): Buffer { + return gitObjectSha('blob', bytes) +} + +export function skillPackageGitTreeSha(entries: readonly SkillGitTreeFileEntry[]): string { + const root: SkillGitTreeDirectory = { directories: new Map(), files: [] } + for (const entry of entries) { + const parts = entry.path.split('/') + const filename = parts.pop() as string + let directory = root + for (const part of parts) { + let child = directory.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + directory.directories.set(part, child) + } + directory = child + } + directory.files.push({ filename, executable: entry.executable, blobSha: entry.blobSha }) + } + + function hashDirectory(directory: SkillGitTreeDirectory): Buffer { + const children = [ + ...[...directory.directories].map(([name, child]) => ({ + mode: '40000', + name, + hash: hashDirectory(child) + })), + ...directory.files.map((file) => ({ + mode: file.executable ? '100755' : '100644', + name: file.filename, + hash: file.blobSha + })) + ].sort((left, right) => { + // Git orders tree entries as raw bytes with directory names read as `name/`. + const leftName = left.mode === '40000' ? `${left.name}/` : left.name + const rightName = right.mode === '40000' ? `${right.name}/` : right.name + return Buffer.from(leftName).compare(Buffer.from(rightName)) + }) + const body = Buffer.concat( + children.map(({ mode, name, hash }) => + Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) + ) + ) + return gitObjectSha('tree', body) + } + + return hashDirectory(root).toString('hex') +} diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 8189ee0b28b..d518bd05d43 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -3,12 +3,19 @@ import type { Dirent } from 'node:fs' import { lstat, open, opendir } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness' +import { + gitBlobSha, + skillPackageGitTreeSha, + type SkillGitTreeFileEntry +} from './skill-git-tree-identity' type ObservedSkillFile = SkillBundleFileIdentity export type ObservedSkillPackage = { files: ObservedSkillFile[] observedDigest: string + /** Git tree sha of the raw bytes — comparable against the updater lock's skillFolderHash. */ + observedGitTreeSha: string } export const SKILL_PACKAGE_OBSERVATION_LIMITS = { @@ -136,6 +143,7 @@ export async function observeSkillPackage( limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS ): Promise { const files: ObservedSkillFile[] = [] + const treeEntries: SkillGitTreeFileEntry[] = [] const caseFoldedPaths = new Map() let entryCount = 0 let totalBytes = 0 @@ -197,7 +205,9 @@ export async function observeSkillPackage( limits.maximumSingleFileBytes ) totalBytes += bytes.length - files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0)) + const executable = (fileStat.mode & 0o111) !== 0 + files.push(describeObservedSkillFile(manifestPath, bytes, executable)) + treeEntries.push({ path: manifestPath, executable, blobSha: gitBlobSha(bytes) }) } else { throw new Error('skill-package-special-file') } @@ -205,7 +215,11 @@ export async function observeSkillPackage( } await visit(packageRoot, 0) - return { files, observedDigest: skillPackageDigest(files) } + return { + files, + observedDigest: skillPackageDigest(files), + observedGitTreeSha: skillPackageGitTreeSha(treeEntries) + } } export function matchingKnownSnapshot( diff --git a/src/main/skills/skill-plugin-cache-scan.test.ts b/src/main/skills/skill-plugin-cache-scan.test.ts index 442af48ce96..17f1c8e1eb9 100644 --- a/src/main/skills/skill-plugin-cache-scan.test.ts +++ b/src/main/skills/skill-plugin-cache-scan.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, describe, expect, it } from 'vitest' +import { isSkillScanIssueNeedingAttention } from '../../shared/skill-freshness' import { + MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES, MAXIMUM_PLUGIN_SCAN_ISSUES, scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' @@ -463,6 +465,73 @@ describe('plugin skill candidate scan', () => { } ) + // Why: linking skills out of the cache is ordinary vendor packaging, so 'outside-root' is + // what fills the display budget on a real install — before any read failure is reached. + async function createCacheBehindSpentBudget(prefix: string, loopCount: number): Promise { + const parent = await mkdtemp(join(tmpdir(), prefix)) + temporaryDirectories.push(parent) + const root = join(parent, 'cache') + const outside = join(parent, 'outside') + await mkdir(outside, { recursive: true }) + await mkdir(root, { recursive: true }) + await Promise.all( + Array.from({ length: MAXIMUM_PLUGIN_SCAN_ISSUES }, (_, index) => + symlink(outside, join(root, `aa-linked-${index.toString().padStart(2, '0')}`), 'dir') + ) + ) + // Sorts after the links, so these are read once the budget is already spent. + await Promise.all( + Array.from({ length: loopCount }, (_, index) => { + const name = `zz-loop-${index.toString().padStart(2, '0')}` + return symlink(name, join(root, name), 'dir') + }) + ) + return root + } + + it.skipIf(process.platform === 'win32')( + 'keeps a read failure that lands after the display budget is spent', + async () => { + const root = await createCacheBehindSpentBudget('orca-plugin-attention-eviction-', 1) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) + + // Why: a read failure is the only issue that can take the headline off "all up to + // date". Evicting it for display budget reports all-clear over a path that could be + // hiding a stale copy — the bounds that filled the budget say nothing about it. + expect(result.issues).toContainEqual({ + path: join(root, 'zz-loop-00'), + reason: 'io-error', + errorCode: 'ELOOP' + }) + const inventoryIssues = result.issues.map((issue) => ({ + rootId: 'plugin-cache', + sourceLabel: 'Plugin cache', + ...issue + })) + expect(inventoryIssues.some(isSkillScanIssueNeedingAttention)).toBe(true) + } + ) + + it.skipIf(process.platform === 'win32')( + 'bounds how many read failures outrank the display budget', + async () => { + const root = await createCacheBehindSpentBudget( + 'orca-plugin-attention-bound-', + MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES + 4 + ) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) + + // Why: outranking the budget is what makes this class unbounded, so a tree full of + // unreadable folders must not be able to pin one issue per folder in memory. + expect(result.issues.filter((issue) => issue.reason === 'io-error')).toHaveLength( + MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES + ) + expect(result.issues).toContainEqual({ path: root, reason: 'issue-limit', errorCode: null }) + } + ) + it('reports the bound that ended the walk even with the issue budget spent', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-plugin-truncating-issue-')) temporaryDirectories.push(root) diff --git a/src/main/skills/skill-plugin-cache-scan.ts b/src/main/skills/skill-plugin-cache-scan.ts index 78fbc53cd7b..7f151e8475b 100644 --- a/src/main/skills/skill-plugin-cache-scan.ts +++ b/src/main/skills/skill-plugin-cache-scan.ts @@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs' import { opendir, realpath, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import { + isSkillScanAttentionReason, isTruncatingSkillScanReason, type SkillFreshnessScanIssueReason } from '../../shared/skill-freshness' @@ -19,6 +20,11 @@ const MAXIMUM_NESTED_SKILL_DEPTH = 2 const MAXIMUM_PLUGIN_SCAN_ENTRIES = 16_384 export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64 export const MAXIMUM_PLUGIN_SCAN_ISSUES = 16 +// Why: an attention issue outranks the display budget, so nothing else bounds how many a +// pathological tree can pin in memory. One is all the badge needs to be truthful; a few +// more give the dialog enough distinct paths to read as a pattern, and 'issue-limit' still +// says there are others. +export const MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES = 4 const SKILL_FILE_NAME = 'SKILL.md' export type KnownPluginSkillCandidate = { @@ -52,6 +58,7 @@ export async function scanKnownPluginSkillCandidates( const issues: KnownPluginSkillScanIssue[] = [] const issueKeys = new Set() const visited = new Set() + let attentionIssueCount = 0 let resolvedRoot: string | null = null let entryCount = 0 let limitReached = false @@ -70,9 +77,15 @@ export async function scanKnownPluginSkillCandidates( if (issueKeys.has(key)) { return } + // Why: an attention issue is the only thing that can turn the headline off "all up to + // date", so evicting one for display budget makes Orca report all-clear over a read + // failure. Reserving a few keeps that unbounded on a tree full of unreadable folders. + const attention = + isSkillScanAttentionReason(reason) && + attentionIssueCount < MAXIMUM_PLUGIN_SCAN_ATTENTION_ISSUES // Why: the bound that ended the walk is the one issue the dialog cannot do without // — dropping it for display budget is what lets a truncated scan report all-clear. - const required = explainsCandidate || isTruncatingSkillScanReason(reason) + const required = explainsCandidate || attention || isTruncatingSkillScanReason(reason) // Why: this budget bounds what the dialog lists, not how far the scan reaches. // Ending the walk here would truncate coverage over a display limit — and since // Orca's own bounds no longer raise attention, it would do so silently. @@ -87,6 +100,9 @@ export async function scanKnownPluginSkillCandidates( return } issueKeys.add(key) + if (isSkillScanAttentionReason(reason)) { + attentionIssueCount += 1 + } issues.push({ path, reason, errorCode: code }) } diff --git a/src/main/skills/skill-update-convergence.test.ts b/src/main/skills/skill-update-convergence.test.ts new file mode 100644 index 00000000000..86e4f395e6d --- /dev/null +++ b/src/main/skills/skill-update-convergence.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFreshnessInstallation, SkillKnownSnapshot } from '../../shared/skill-freshness' +import { convergableSkillNames } from './skill-update-convergence' + +function placement( + name: string, + observedPackageDigest: string | null, + topology: SkillFreshnessInstallation['topology'] = 'canonical-copy' +): SkillFreshnessInstallation { + return { + id: `${name}:${observedPackageDigest}:${topology}`, + name, + rootId: 'home', + providers: [], + sourceKind: 'home', + sourceLabel: 'home', + unresolvedPath: `~/.agents/skills/${name}`, + resolvedPath: `/home/u/.agents/skills/${name}`, + physicalIdentity: '1:1', + topology, + status: 'outdated', + installedReleaseRevision: null, + installedAppVersion: null, + currentReleaseRevision: 8, + currentPackageDigest: 'digest-current', + currentAppVersion: '1.4.160', + observedPackageDigest, + errorCategory: null + } +} + +function revision(packageDigest: string, gitTreeSha: string): SkillKnownSnapshot { + return { releaseRevision: 1, packageDigest, gitTreeSha, files: [] } +} + +describe('convergableSkillNames', () => { + // The real reported case: the lock records the stub tree (091d9bcc) while disk + // still holds the pre-stub revision (f3727995). `skills update` compares lock to + // source, sees no work, exits 0 and writes nothing — forever. + it('drops a skill whose lock records a revision the disk does not have', () => { + const result = convergableSkillNames( + [placement('orca-linear', 'digest-pre-stub')], + new Map([['orca-linear', '091d9bcc']]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ] + } + ) + expect([...result]).toEqual([]) + }) + + // The legitimate case that must NOT be gated: lock and disk agree, and the source + // has simply moved ahead of what this build bundles. The update really can converge. + it('keeps a skill whose lock matches disk even when it is outdated', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-installed')], + new Map([['orca-cli', 'aaaa1111']]), + { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + it('keeps a skill whose disk content matches no known revision', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-unknown')], + new Map([['orca-cli', 'aaaa1111']]), + { 'orca-cli': [revision('digest-other', 'bbbb2222')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + it('keeps a skill with no observable placement', () => { + const result = convergableSkillNames( + [placement('orca-cli', null)], + new Map([['orca-cli', 'aaaa1111']]), + { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // One placement still matching the lock means the command has an anchor to write. + it('keeps a skill when any placement still matches the lock', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-installed'), placement('orca-cli', 'digest-pre-stub')], + new Map([['orca-cli', 'aaaa1111']]), + { + 'orca-cli': [ + revision('digest-installed', 'aaaa1111'), + revision('digest-pre-stub', 'f3727995') + ] + } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // A lock hash we cannot place is not evidence the command is stuck. + it('keeps a skill whose lock names no revision we know', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-pre-stub')], + new Map([['orca-cli', 'not-a-known-tree']]), + { 'orca-cli': [revision('digest-pre-stub', 'f3727995')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // Why: `diskTreeShas` silently drops digests that match no known revision, so a + // stale copy sitting beside an unidentifiable one must NOT gate the name — the + // unknown half could be anything, including a copy the command would converge. + it('keeps a skill when one placement is stale but another is unidentifiable', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-pre-stub'), placement('orca-cli', 'digest-unknown')], + new Map([['orca-cli', '091d9bcc']]), + { + 'orca-cli': [revision('digest-pre-stub', 'f3727995'), revision('digest-stub', '091d9bcc')] + } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // Why: copies the command never writes must not defeat the gate. An + // unidentifiable plugin-cache repack would otherwise read as an unresolved + // placement and re-arm the unwinnable update on the drifted canonical. + it('ignores an unidentifiable plugin-cache copy when judging the canonical', () => { + const result = convergableSkillNames( + [ + placement('orca-linear', 'digest-pre-stub'), + placement('orca-linear', 'digest-cache-repack', 'plugin-cache') + ], + new Map([['orca-linear', '091d9bcc']]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ] + } + ) + expect([...result]).toEqual([]) + }) + + // A cache copy parked at the lock's own revision is not an anchor either — + // the command only writes the canonical, which is still drifted. + it('ignores a plugin-cache copy that matches the lock', () => { + const result = convergableSkillNames( + [ + placement('orca-linear', 'digest-pre-stub'), + placement('orca-linear', 'digest-stub', 'plugin-cache') + ], + new Map([['orca-linear', '091d9bcc']]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ] + } + ) + expect([...result]).toEqual([]) + }) + + it('judges each locked skill independently', () => { + const result = convergableSkillNames( + [placement('orca-linear', 'digest-pre-stub'), placement('orca-cli', 'digest-installed')], + new Map([ + ['orca-linear', '091d9bcc'], + ['orca-cli', 'aaaa1111'] + ]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ], + 'orca-cli': [revision('digest-installed', 'aaaa1111')] + } + ) + expect([...result]).toEqual(['orca-cli']) + }) +}) diff --git a/src/main/skills/skill-update-convergence.ts b/src/main/skills/skill-update-convergence.ts new file mode 100644 index 00000000000..41889058f3f --- /dev/null +++ b/src/main/skills/skill-update-convergence.ts @@ -0,0 +1,73 @@ +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation, + type SkillKnownSnapshot +} from '../../shared/skill-freshness' + +/** + * Names `skills update` can still move, judged against what it believes it installed. + * + * The updater decides what to do by comparing its lock's `skillFolderHash` against + * the source tree; it never reads disk. So once the lock records a revision the + * filesystem does not actually have, the command reports "up to date", exits 0 and + * writes nothing — no retry converges it. Offering an update there promises work the + * command cannot do, which is exactly what `eligibleSkillUpdateNames` exists to avoid. + * + * The honest signal is the lock's hash versus the `gitTreeSha` of the revision the + * DISK hashes to. Deliberately not versus the bundled manifest: the updater pulls from + * the source repo, which legitimately runs ahead of what this build ships, and gating + * on the bundle would withhold real updates. When lock and disk agree, an update is + * still genuinely available and stays on offer. + * + * Unknown either way (no lock entry, or disk content matching no known revision) is + * left eligible — silence is not evidence the command is stuck. + */ +export function convergableSkillNames( + installations: readonly SkillFreshnessInstallation[], + globalSkillLocks: ReadonlyMap, + knownSnapshots: Readonly> +): ReadonlySet { + const convergable = new Set(globalSkillLocks.keys()) + for (const [name, lockHash] of globalSkillLocks) { + // Why: judged only over the placements the command writes, like eligibility + // itself. A plugin-cache or repo copy is never the command's to converge, so + // it must neither gate the name nor rescue it — an unidentifiable cache copy + // (or one parked at the lock's own revision) would otherwise defeat the gate + // and re-arm the unwinnable update. + const digests = installations + .filter( + (entry) => + entry.name === name && + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && + entry.observedPackageDigest + ) + .map((entry) => entry.observedPackageDigest) + if (digests.length === 0) { + continue + } + const revisions = knownSnapshots[name] ?? [] + const diskTreeShas = digests + .map((digest) => revisions.find((revision) => revision.packageDigest === digest)?.gitTreeSha) + .filter((sha): sha is string => Boolean(sha)) + // Why: only claim the lock is stale when BOTH sides are positively identified — + // the lock names a revision we know, and every placement resolves to a different + // known revision. A lock hash we cannot place (a source we do not bundle, a + // revision older than the registry) is not evidence of anything, and gating on it + // would withhold updates that would have worked. + const lockNamesAKnownRevision = revisions.some((entry) => entry.gitTreeSha === lockHash) + // `diskTreeShas` drops digests that match no known revision, so requiring every + // observed digest to resolve is what keeps `every` honest: without it, one stale + // copy beside one unidentifiable copy would gate the name off the resolved half + // alone, contradicting the unknown-stays-eligible rule above. + const everyPlacementResolved = diskTreeShas.length === digests.length + if ( + lockNamesAKnownRevision && + everyPlacementResolved && + diskTreeShas.length > 0 && + diskTreeShas.every((sha) => sha !== lockHash) + ) { + convergable.delete(name) + } + } + return convergable +} diff --git a/src/main/skills/skill-update-outcome.test.ts b/src/main/skills/skill-update-outcome.test.ts index 4aa2ce401b2..4fdf8a4ff6d 100644 --- a/src/main/skills/skill-update-outcome.test.ts +++ b/src/main/skills/skill-update-outcome.test.ts @@ -1,15 +1,24 @@ -import { describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' import type { SkillFreshnessInstallation, SkillFreshnessStatus, SkillInstallationTopology } from '../../shared/skill-freshness' +import { inventorySkillFreshness } from './skill-freshness-inventory' +import { observeSkillPackage } from './skill-package-identity' +import { readGloballyUpdatableSkillLocks } from './skill-update-registration' import { skillUpdateFailedNames } from './skill-update-outcome' +const noLocks = new Map() + function placement( name: string, status: SkillFreshnessStatus, - topology: SkillInstallationTopology = 'canonical-copy' + topology: SkillInstallationTopology = 'canonical-copy', + observedGitTreeSha: string | null = null ): SkillFreshnessInstallation { return { id: `${name}-${topology}-${status}`, @@ -29,49 +38,55 @@ function placement( currentPackageDigest: 'current', currentAppVersion: '2.0.0', observedPackageDigest: 'current', + observedGitTreeSha, errorCategory: null } } describe('skillUpdateFailedNames', () => { it('treats a convergent copy that is now current as landed', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')])).toEqual([]) + expect( + skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')], noLocks) + ).toEqual([]) }) it('reports a copy the run left outdated', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([ - 'orca-cli' - ]) + expect( + skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')], noLocks) + ).toEqual(['orca-cli']) }) it('reports a half-written bundle instead of reading it as success', () => { // The old "still eligible?" test passed here: an unrecognized copy is not // eligible either, so a corrupt write looked identical to a clean update. - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'unrecognized')])).toEqual([ - 'orca-cli' - ]) + expect( + skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'unrecognized')], noLocks) + ).toEqual(['orca-cli']) }) it('reports an unreadable copy', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'inaccessible')])).toEqual([ - 'orca-cli' - ]) + expect( + skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'inaccessible')], noLocks) + ).toEqual(['orca-cli']) }) it('reports a skill the run removed outright', () => { - expect(skillUpdateFailedNames(['orca-cli'], [])).toEqual(['orca-cli']) + expect(skillUpdateFailedNames(['orca-cli'], [], noLocks)).toEqual(['orca-cli']) }) it('accepts a revision newer than this build ships', () => { // The CLI pulls from the source repo, which runs ahead of the bundled manifest. - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'newer-known')])).toEqual([]) + expect( + skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'newer-known')], noLocks) + ).toEqual([]) }) it('ignores placements the update command never writes to', () => { expect( skillUpdateFailedNames( ['orca-cli'], - [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'plugin-cache')] + [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'plugin-cache')], + noLocks ) ).toEqual([]) }) @@ -80,7 +95,8 @@ describe('skillUpdateFailedNames', () => { expect( skillUpdateFailedNames( ['orca-cli'], - [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')] + [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')], + noLocks ) ).toEqual(['orca-cli']) }) @@ -89,8 +105,148 @@ describe('skillUpdateFailedNames', () => { expect( skillUpdateFailedNames( ['orca-cli', 'orchestration'], - [placement('orca-cli', 'current'), placement('orchestration', 'outdated')] + [placement('orca-cli', 'current'), placement('orchestration', 'outdated')], + noLocks ) ).toEqual(['orchestration']) }) + + it('treats unrecognized content whose tree sha matches the lock as landed', () => { + // Source-repo HEAD routinely runs ahead of the bundled registry; the lock is + // the CLI's own record of what it wrote. + expect( + skillUpdateFailedNames( + ['orca-cli'], + [placement('orca-cli', 'unrecognized', 'canonical-copy', 'ahead-of-bundle')], + new Map([['orca-cli', 'ahead-of-bundle']]) + ) + ).toEqual([]) + }) + + it('still reports unrecognized content whose bytes do not match the lock', () => { + expect( + skillUpdateFailedNames( + ['orca-cli'], + [placement('orca-cli', 'unrecognized', 'canonical-copy', 'half-written-bytes')], + new Map([['orca-cli', 'ahead-of-bundle']]) + ) + ).toEqual(['orca-cli']) + }) + + it('still reports unrecognized content when the skill has no lock entry', () => { + expect( + skillUpdateFailedNames( + ['orca-cli'], + [placement('orca-cli', 'unrecognized', 'canonical-copy', 'ahead-of-bundle')], + noLocks + ) + ).toEqual(['orca-cli']) + }) + + it('never forgives an outdated copy, even at the lock hash', () => { + // Lock == disk on an outdated copy means the command provably wrote nothing. + expect( + skillUpdateFailedNames( + ['orca-cli'], + [placement('orca-cli', 'outdated', 'canonical-copy', 'locked-revision')], + new Map([['orca-cli', 'locked-revision']]) + ) + ).toEqual(['orca-cli']) + }) + + it('does not let a lock-matching canonical copy excuse a degraded alias', () => { + expect( + skillUpdateFailedNames( + ['orca-cli'], + [ + placement('orca-cli', 'unrecognized', 'canonical-copy', 'ahead-of-bundle'), + placement('orca-cli', 'inaccessible', 'provider-alias') + ], + new Map([['orca-cli', 'ahead-of-bundle']]) + ) + ).toEqual(['orca-cli']) + }) +}) + +describe('skillUpdateFailedNames over a real inventory', () => { + const repoRoot = resolve(__dirname, '..', '..', '..') + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) + }) + + async function postCutFixture(): Promise<{ homeDir: string; installedTreeSha: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-outcome-')) + temporaryDirectories.push(root) + const homeDir = join(root, 'home') + const skillDir = join(homeDir, '.agents', 'skills', 'orca-cli') + await mkdir(skillDir, { recursive: true }) + // Current bytes plus one upstream edit: content no snapshot in this build's + // registry has ever seen, exactly what `skills update` installs after the + // source repo moves past the release cut. + const current = await readFile(join(repoRoot, 'skills', 'orca-cli', 'SKILL.md')) + await writeFile( + join(skillDir, 'SKILL.md'), + Buffer.concat([current, Buffer.from('\nUpstream edit published after this build.\n')]) + ) + return { homeDir, installedTreeSha: (await observeSkillPackage(skillDir)).observedGitTreeSha } + } + + async function writeLock(homeDir: string, skillFolderHash: string): Promise { + await writeFile( + join(homeDir, '.agents', '.skill-lock.json'), + JSON.stringify({ + version: 3, + skills: { + 'orca-cli': { + skillFolderHash, + skillPath: 'skills/orca-cli', + source: 'github.com/stablyai/orca' + } + } + }) + ) + } + + it('accepts post-cut source content when the lock records exactly those bytes', async () => { + const { homeDir, installedTreeSha } = await postCutFixture() + await writeLock(homeDir, installedTreeSha) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: 'test', + homeDir, + resourceRoot: join(repoRoot, 'resources'), + repos: [] + }) + const locks = await readGloballyUpdatableSkillLocks({ homeDir }) + + // Guard the premise: this is the unrecognized path, not an accidental match. + const canonical = inventory.installations.filter( + (entry) => entry.name === 'orca-cli' && entry.topology === 'canonical-copy' + ) + expect(canonical).toHaveLength(1) + expect(canonical[0].status).toBe('unrecognized') + + expect(skillUpdateFailedNames(['orca-cli'], inventory.installations, locks)).toEqual([]) + }) + + it('keeps failing the same content when the lock names different bytes', async () => { + const { homeDir } = await postCutFixture() + await writeLock(homeDir, 'f'.repeat(40)) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: 'test', + homeDir, + resourceRoot: join(repoRoot, 'resources'), + repos: [] + }) + const locks = await readGloballyUpdatableSkillLocks({ homeDir }) + + expect(skillUpdateFailedNames(['orca-cli'], inventory.installations, locks)).toEqual([ + 'orca-cli' + ]) + }) }) diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts index 6d75d4b5efc..9a15b856644 100644 --- a/src/main/skills/skill-update-outcome.ts +++ b/src/main/skills/skill-update-outcome.ts @@ -11,8 +11,9 @@ import { * `unrecognized`, a failed write can leave the directory unreadable, and a * removed skill has no convergent placement at all — each would read as * "updated" and show a green check over a broken skill. So demand a positive - * signal instead: every placement the command writes to has to come back as a - * revision we recognise. + * signal instead: every placement the command writes to has to come back as + * either a revision we recognise or the exact bytes the updater's lock says it + * installed. * * Known limit: the topology filter runs before the status check, so a placement * that degrades OUT of the convergent set is dropped from the judgment rather @@ -23,17 +24,38 @@ import { */ export function skillUpdateFailedNames( names: readonly string[], - installations: readonly SkillFreshnessInstallation[] + installations: readonly SkillFreshnessInstallation[], + globalSkillLocks: ReadonlyMap ): string[] { return names.filter((name) => { + const lockHash = globalSkillLocks.get(name) const convergent = installations.filter( (entry) => entry.name === name && SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) ) if (convergent.length === 0) { return true } - // `newer-known` counts as landed: the CLI pulls from the source repo, which - // can be ahead of the revision this build ships in its manifest. - return convergent.some((entry) => entry.status !== 'current' && entry.status !== 'newer-known') + return convergent.some((entry) => !skillPlacementLanded(entry, lockHash)) }) } + +function skillPlacementLanded( + entry: SkillFreshnessInstallation, + lockHash: string | undefined +): boolean { + if (entry.status === 'current' || entry.status === 'newer-known') { + return true + } + // Why: the CLI installs source-repo HEAD, which runs ahead of the revisions this + // build bundles, so a clean update routinely hashes `unrecognized`. The lock is + // the CLI's own record of what it installed — disk matching lock means the + // command did its job and the bundled registry simply has not seen that revision + // yet. Everything else unrecognized (half-written, no lock entry, mismatch) + // still fails, and `outdated` is never forgiven: lock == disk there means the + // command provably wrote nothing. + return ( + entry.status === 'unrecognized' && + lockHash !== undefined && + entry.observedGitTreeSha === lockHash + ) +} diff --git a/src/main/skills/skill-update-registration.test.ts b/src/main/skills/skill-update-registration.test.ts new file mode 100644 index 00000000000..6ddc60bf41a --- /dev/null +++ b/src/main/skills/skill-update-registration.test.ts @@ -0,0 +1,75 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readGloballyUpdatableSkillNames } from './skill-update-registration' + +const temporaryDirectories: string[] = [] + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-registration-')) + temporaryDirectories.push(root) + return root +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('global skill update registration', () => { + it('reads only updateable entries from the external updater lock', async () => { + const homeDir = await temporaryRoot() + await mkdir(join(homeDir, '.agents'), { recursive: true }) + await writeFile( + join(homeDir, '.agents', '.skill-lock.json'), + JSON.stringify({ + version: 3, + skills: { + orchestration: { + skillFolderHash: 'hash', + skillPath: 'skills/orchestration/SKILL.md', + source: 'stablyai/orca' + }, + copied: {}, + emptyHash: { + skillFolderHash: '', + skillPath: 'skills/empty-hash/SKILL.md', + source: 'stablyai/orca' + }, + emptyPath: { + skillFolderHash: 'hash', + skillPath: '', + source: 'stablyai/orca' + } + } + }) + ) + + await expect(readGloballyUpdatableSkillNames({ homeDir, stateHome: null })).resolves.toEqual( + new Set(['orchestration']) + ) + }) + + it('uses the XDG state lock when configured', async () => { + const root = await temporaryRoot() + const stateHome = join(root, 'state') + await mkdir(join(stateHome, 'skills'), { recursive: true }) + await writeFile( + join(stateHome, 'skills', '.skill-lock.json'), + JSON.stringify({ + version: 3, + skills: { + 'orca-cli': { + skillFolderHash: 'hash', + skillPath: 'skills/orca-cli/SKILL.md', + source: 'stablyai/orca' + } + } + }) + ) + + await expect(readGloballyUpdatableSkillNames({ homeDir: root, stateHome })).resolves.toEqual( + new Set(['orca-cli']) + ) + }) +}) diff --git a/src/main/skills/skill-update-registration.ts b/src/main/skills/skill-update-registration.ts new file mode 100644 index 00000000000..66231d71fbe --- /dev/null +++ b/src/main/skills/skill-update-registration.ts @@ -0,0 +1,83 @@ +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const GLOBAL_SKILL_LOCK_SCHEMA_VERSION = 3 + +type SkillUpdateRegistrationArgs = { + homeDir?: string + stateHome?: string | null +} + +function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string { + const stateHome = + args.stateHome === undefined + ? args.homeDir === undefined + ? (process.env.XDG_STATE_HOME ?? null) + : null + : args.stateHome + return stateHome + ? join(stateHome, 'skills', '.skill-lock.json') + : join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json') +} + +export async function readGloballyUpdatableSkillNames( + args: SkillUpdateRegistrationArgs = {} +): Promise> { + return new Set((await readGloballyUpdatableSkillLocks(args)).keys()) +} + +/** + * Each updatable skill's recorded `skillFolderHash`, keyed by name. + * + * The hash is what the updater believes it installed. It is deliberately + * exposed alongside the names because `skills update` decides what to do by + * comparing this against the source and never reads disk — so when it disagrees + * with the bytes actually on disk, the command can only no-op. + */ +export async function readGloballyUpdatableSkillLocks( + args: SkillUpdateRegistrationArgs = {} +): Promise> { + try { + const parsed = JSON.parse(await readFile(globalSkillLockPath(args), 'utf8')) as { + version?: unknown + skills?: unknown + } + if ( + typeof parsed.version !== 'number' || + parsed.version < GLOBAL_SKILL_LOCK_SCHEMA_VERSION || + !parsed.skills || + typeof parsed.skills !== 'object' || + Array.isArray(parsed.skills) + ) { + return new Map() + } + + return new Map( + Object.entries(parsed.skills) + .filter(([, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entry = value as { + skillFolderHash?: unknown + skillPath?: unknown + source?: unknown + } + return ( + typeof entry.skillFolderHash === 'string' && + entry.skillFolderHash.length > 0 && + typeof entry.skillPath === 'string' && + entry.skillPath.length > 0 && + typeof entry.source === 'string' && + entry.source.length > 0 + ) + }) + .map( + ([name, value]) => [name, (value as { skillFolderHash: string }).skillFolderHash] as const + ) + ) + } catch { + return new Map() + } +} diff --git a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts new file mode 100644 index 00000000000..ff0fcf6e02d --- /dev/null +++ b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts @@ -0,0 +1,245 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getEnterpriseGitHubRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getUpstreamStatusMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() +})) +vi.mock('../gitlab/merge-request-creation', () => ({ createGitLabMergeRequest: vi.fn() })) +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) +vi.mock('../azure-devops/pull-request-creation', () => ({ + createAzureDevOpsPullRequest: vi.fn(), + isAzureDevOpsReviewCreationAuthenticated: vi.fn() +})) +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) +vi.mock('../gitea/pull-request-creation', () => ({ + createGiteaPullRequest: vi.fn(), + isGiteaReviewCreationAuthenticated: vi.fn() +})) +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) +vi.mock('../gitlab/gl-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + glabExecFileAsync: glabExecFileAsyncMock, + glabRepoExecOptions: (repoPath: string) => ({ cwd: repoPath }) +})) +vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) +vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) +vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) + +import { createHostedReview } from './hosted-review-creation' + +// Why: a directory-only ignore rule (`node_modules/`) never matches the +// worktree's symlink, so Git reports it untracked. Without the exclusion the +// dirty preflight blocks Create PR and tells the user to commit an entry they +// cannot commit — it is a symlink Orca created. +describe('createHostedReview with shared symlinks', () => { + let worktree: string + let statusOutput: string + + const createPr = (sharedLinkPaths?: string[]): ReturnType => + createHostedReview( + worktree, + { provider: 'github', base: 'main', head: 'feature', title: 'Feature' }, + null, + sharedLinkPaths ? { sharedLinkPaths } : {} + ) + + beforeEach(() => { + worktree = mkdtempSync(join(tmpdir(), 'orca-hosted-shared-')) + mkdirSync(join(worktree, 'primary-node-modules')) + symlinkSync(join(worktree, 'primary-node-modules'), join(worktree, 'node_modules'), 'dir') + // Default: git reports only the shared symlink as untracked. + statusOutput = '?? node_modules\0' + + for (const mock of [ + createGitHubPullRequestMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getEnterpriseGitHubRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock + ]) { + mock.mockReset() + } + + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + getUpstreamStatusMock.mockResolvedValue({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 0 + }) + createGitHubPullRequestMock.mockResolvedValue({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'status') { + return { stdout: statusOutput, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: 'Feature title\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + }) + + afterEach(() => { + rmSync(worktree, { recursive: true, force: true }) + }) + + it('blocks creation when the shared symlink is not declared', async () => { + await expect(createPr()).resolves.toEqual( + expect.objectContaining({ ok: false, code: 'validation' }) + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + it('creates the pull request when the untracked entry is a declared shared symlink', async () => { + await expect(createPr(['node_modules'])).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + expect(createGitHubPullRequestMock).toHaveBeenCalledOnce() + }) + + // The control that matters: real work must never be waved through. + it('still blocks when a genuine untracked file sits beside the shared symlink', async () => { + statusOutput = '?? node_modules\0?? scratch.txt\0' + + await expect(createPr(['node_modules'])).resolves.toEqual( + expect.objectContaining({ ok: false, code: 'validation' }) + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + it('still blocks on a modified tracked file beside the shared symlink', async () => { + statusOutput = '?? node_modules\0 M src/app.ts\0' + + await expect(createPr(['node_modules'])).resolves.toEqual( + expect.objectContaining({ ok: false, code: 'validation' }) + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + // Why: only a real symlink is excluded. `notes` is declared shared but exists + // as a regular file here, so it is the user's work and must still block. + it('still blocks when a declared name exists as a regular file', async () => { + writeFileSync(join(worktree, 'notes'), 'user work\n') + statusOutput = '?? notes\0' + + await expect(createPr(['node_modules', 'notes'])).resolves.toEqual( + expect.objectContaining({ ok: false, code: 'validation' }) + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + // Why: only an *untracked* record can be Orca's artifact. A tracked change at a + // declared path is committable work, so waving it through would create a review + // off a branch missing it. + it('still blocks on a tracked change at the declared shared path', async () => { + // Both paths are declared shared and both are real symlinks, so only the + // untracked/tracked distinction can keep this from being waved through. + symlinkSync(join(worktree, 'primary-node-modules'), join(worktree, 'tracked-link'), 'dir') + statusOutput = '?? node_modules\0 M tracked-link\0' + + await expect(createPr(['node_modules', 'tracked-link'])).resolves.toEqual( + expect.objectContaining({ ok: false, code: 'validation' }) + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + + it('does not mistake a rename origin for an untracked shared path', async () => { + // `R renamed.txt\0node_modules\0` — the origin field must be consumed, not + // read as its own `?? node_modules` record. + statusOutput = 'R renamed.txt\0node_modules\0' + + await expect(createPr(['node_modules'])).resolves.toEqual( + expect.objectContaining({ ok: false, code: 'validation' }) + ) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index cb593ae6a00..43de5f42518 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -387,7 +387,7 @@ describe('createHostedReview', () => { wslDistro: 'Ubuntu' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['status', '--porcelain'], + ['status', '--porcelain', '-z'], expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) ) expect(getUpstreamStatusMock).toHaveBeenCalledWith('/repo', undefined, { diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 618ef9bb684..75e3189f447 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -23,6 +23,8 @@ import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/g import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' import { gitOptionalLocksDisabledEnv } from '../git/runner' +import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelain-v1-records' +import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection' import { resolveDefaultBaseRefViaExec } from '../git/repo' import { getUpstreamStatus } from '../git/upstream' import { getProjectSlug } from '../gitlab/client' @@ -196,15 +198,43 @@ async function hasUncommittedChanges( ) } // Why: the relay restricts generic git.exec, so use the structured status RPC for SSH dirty checks. + // No shared-link exclusion here: remote worktree creation skips the symlink + // and shared-directory passes entirely, so a remote worktree never has one. return (await provider.getStatus(repoPath)).entries.length > 0 } - const { stdout } = await gitExecFileAsync(['status', '--porcelain'], { + // Why: `-z` keeps paths raw so the shared-link comparison below can't be + // defeated by Git quoting a path with spaces or non-ASCII bytes. + const { stdout } = await gitExecFileAsync(['status', '--porcelain', '-z'], { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), // Why: don't take Git's optional index lock while the user may be running fetch/pull/rebase in a terminal. env: gitOptionalLocksDisabledEnv() }) - return stdout.trim().length > 0 + const records = parsePorcelainV1Records(stdout) + if (records.length === 0) { + return false + } + return await anyRecordIsUserDirt(repoPath, records, options.sharedLinkPaths ?? []) +} + +/** True when any record is real user work rather than a shared symlink Orca put + * in the worktree. + * + * Fails closed on purpose: anything not positively identified as an Orca-owned + * untracked symlink counts as dirty. A false "clean" would let a review be + * created off a branch missing the user's work. */ +async function anyRecordIsUserDirt( + worktreePath: string, + records: readonly PorcelainV1Record[], + sharedLinkPaths: readonly string[] +): Promise { + if (sharedLinkPaths.length === 0 || !records.some((record) => record.xy === '??')) { + return true + } + // Why: only entries that are configured AND really symlinks are excluded, so a + // regular file the user created at a configured name still blocks creation. + const sharedLinks = new Set(await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths)) + return records.some((record) => record.xy !== '??' || !sharedLinks.has(record.path)) } async function getHostedReviewUpstreamStatus( diff --git a/src/main/source-control/hosted-review-git-options.ts b/src/main/source-control/hosted-review-git-options.ts index 40b0b6b0c90..eb8dc63f33c 100644 --- a/src/main/source-control/hosted-review-git-options.ts +++ b/src/main/source-control/hosted-review-git-options.ts @@ -4,6 +4,10 @@ export type HostedReviewLocalGitOptions = { export type HostedReviewExecutionOptions = { localGitExecOptions?: HostedReviewLocalGitOptions + /** Paths Orca may have symlinked into this worktree. An untracked entry that + * is one of these is Orca's own artifact, not work the user can commit, so it + * must not read as "dirty" and block review creation. */ + sharedLinkPaths?: readonly string[] } export function getHostedReviewLocalGitOptions( diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index c05b6c33bfe..0412c97994d 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -301,7 +301,7 @@ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { const text = payload.toString('utf-8') const msg = JSON.parse(text) as JsonRpcMessage if (msg.jsonrpc !== '2.0') { - throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) + throw new Error(`Invalid JSON-RPC version: ${String((msg as Record).jsonrpc)}`) } return msg } diff --git a/src/main/ssh/ssh-connection-manager.test.ts b/src/main/ssh/ssh-connection-manager.test.ts index 2667dc28e1f..3a7a73cbcbf 100644 --- a/src/main/ssh/ssh-connection-manager.test.ts +++ b/src/main/ssh/ssh-connection-manager.test.ts @@ -71,4 +71,27 @@ describe('SshConnectionManager', () => { expect(mockState.instances).toHaveLength(2) expect(manager.getConnection(target.id)).toBe(secondConnection) }) + + it('keeps the replacement when the disconnected attempt resolves late', async () => { + let resolveFirst!: () => void + mockState.connectResults.push( + new Promise((resolve) => { + resolveFirst = resolve + }), + Promise.resolve() + ) + const manager = new SshConnectionManager({ + onStateChange: vi.fn() + }) + + const firstConnect = manager.connect(target) + await manager.disconnect(target.id) + const replacement = await manager.connect(target) + resolveFirst() + + await expect(firstConnect).resolves.not.toBe(replacement) + expect(mockState.instances[0].disconnect).toHaveBeenCalledOnce() + expect(await manager.connect(target)).toBe(replacement) + expect(manager.getConnection(target.id)).toBe(replacement) + }) }) diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 1ab923bee60..94c3f31838e 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -154,10 +154,10 @@ vi.mock('./ssh-config-parser', () => ({ import { SshConnection, - SshConnectionManager, shouldUseSystemSshTransport, type SshConnectionCallbacks } from './ssh-connection' +import { SshConnectionManager } from './ssh-connection-manager' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' import { downloadFileViaSystemSsh, @@ -458,6 +458,50 @@ describe('SshConnection', () => { expect(conn.getState().status).toBe('disconnected') }) + it('rejects late ssh2 ready after disconnect without resurrecting the connection', async () => { + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + + const connectResult = conn.connect().catch((error: Error) => error) + for (let i = 0; i < 5 && clientInstances.length === 0; i++) { + await Promise.resolve() + } + expect(clientInstances).toHaveLength(1) + await conn.disconnect() + + await expect(connectResult).resolves.toMatchObject({ + message: 'SSH connection attempt was cancelled' + }) + expect(conn.getState()).toMatchObject({ status: 'disconnected', error: null }) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'connected' }) + ) + }) + + it('keeps disconnected state when ssh2 reports a late startup error', async () => { + connectBehavior = 'error' + connectErrorMessage = 'Connection lost before handshake' + const callbacks = createCallbacks() + const conn = new SshConnection(createTarget(), callbacks) + + const connectResult = conn.connect().catch((error: Error) => error) + for (let i = 0; i < 5 && clientInstances.length === 0; i++) { + await Promise.resolve() + } + expect(clientInstances).toHaveLength(1) + await conn.disconnect() + + await expect(connectResult).resolves.toMatchObject({ + message: 'Connection lost before handshake' + }) + expect(conn.getState()).toMatchObject({ status: 'disconnected', error: null }) + expect(callbacks.onStateChange).not.toHaveBeenCalledWith( + 'target-1', + expect.objectContaining({ status: 'error' }) + ) + }) + it('getTarget returns a copy of the target', () => { const target = createTarget() const conn = new SshConnection(target, createCallbacks()) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 899fa5b35f3..224f888eadb 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -1371,5 +1371,3 @@ export function shouldUseSystemSshTransport( resolved?.proxyJump != null ) } - -export { SshConnectionManager } from './ssh-connection-manager' diff --git a/src/main/ssh/ssh-provider-authority.test.ts b/src/main/ssh/ssh-provider-authority.test.ts new file mode 100644 index 00000000000..7d960175f76 --- /dev/null +++ b/src/main/ssh/ssh-provider-authority.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + assertSshMutationExpectation, + resetSshConnectionGenerations, + setSshConnectionGeneration +} from './ssh-connection-generation' +import { + getSshProviderAuthority, + isCurrentSshProviderAuthority, + registerSshProviderRequestAbort, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from './ssh-provider-authority' + +describe('SSH provider authority', () => { + beforeEach(() => { + resetSshConnectionGenerations() + resetSshProviderAuthorities() + }) + + it('rotates provider epoch and connection generation atomically', () => { + const initial = getSshProviderAuthority('ssh-a') + const rotated = rotateSshProviderAuthority('ssh-a') + + expect(rotated).toEqual({ + targetId: 'ssh-a', + providerEpoch: expect.any(String), + connectionGeneration: initial.connectionGeneration + 1 + }) + expect(rotated.providerEpoch).not.toBe(initial.providerEpoch) + expect(getSshProviderAuthority('ssh-a')).toEqual(rotated) + expect(() => + assertSshMutationExpectation('ssh-a', 'ssh-a', initial.connectionGeneration) + ).toThrow('SSH connection changed; refresh and try again') + expect(() => + assertSshMutationExpectation('ssh-a', 'ssh-a', rotated.connectionGeneration) + ).not.toThrow() + }) + + it('rejects a stale authority by full-pair equality', () => { + const stale = getSshProviderAuthority('ssh-a') + rotateSshProviderAuthority('ssh-a') + + expect(isCurrentSshProviderAuthority(stale)).toBe(false) + expect(isCurrentSshProviderAuthority(getSshProviderAuthority('ssh-a'))).toBe(true) + }) + + it('checks unknown authority without allocating provider state', () => { + const sequence = (authority: ReturnType): number => + Number.parseInt(authority.providerEpoch.split('-').at(-1) ?? '', 36) + const before = getSshProviderAuthority('before-probe') + + expect( + isCurrentSshProviderAuthority({ + targetId: 'unknown-target', + providerEpoch: 'untrusted-epoch' as typeof before.providerEpoch, + connectionGeneration: 0 + }) + ).toBe(false) + + const after = getSshProviderAuthority('after-probe') + expect(sequence(after) - sequence(before)).toBe(1) + }) + + it('aborts every old-authority provider request once with target isolation', () => { + const authorityA = getSshProviderAuthority('ssh-a') + const authorityB = getSshProviderAuthority('ssh-b') + const controllersA = [new AbortController(), new AbortController()] + const controllerB = new AbortController() + const abortsA = controllersA.map(() => vi.fn()) + const abortB = vi.fn() + controllersA.forEach((controller, index) => { + controller.signal.addEventListener('abort', abortsA[index]) + registerSshProviderRequestAbort(authorityA, controller) + }) + controllerB.signal.addEventListener('abort', abortB) + registerSshProviderRequestAbort(authorityB, controllerB) + + rotateSshProviderAuthority('ssh-a') + rotateSshProviderAuthority('ssh-a') + + expect(abortsA[0]).toHaveBeenCalledOnce() + expect(abortsA[1]).toHaveBeenCalledOnce() + expect(abortB).not.toHaveBeenCalled() + rotateSshProviderAuthority('ssh-b') + expect(abortB).toHaveBeenCalledOnce() + }) + + it('revokes every target when one target rolls the generation session scope', () => { + setSshConnectionGeneration('ssh-a', 2 ** 13 - 1) + const authorityA = getSshProviderAuthority('ssh-a') + const authorityB = getSshProviderAuthority('ssh-b') + const controllerA = new AbortController() + const controllerB = new AbortController() + const abortA = vi.spyOn(controllerA, 'abort') + const abortB = vi.spyOn(controllerB, 'abort') + let oldAuthoritiesWereCurrent = true + controllerA.signal.addEventListener('abort', () => { + oldAuthoritiesWereCurrent = + isCurrentSshProviderAuthority(authorityA) || isCurrentSshProviderAuthority(authorityB) + }) + registerSshProviderRequestAbort(authorityA, controllerA) + registerSshProviderRequestAbort(authorityB, controllerB) + + const rotated = rotateSshProviderAuthority('ssh-a') + + expect(rotated.connectionGeneration).toBe(2 ** 13 + 1) + expect(abortA).toHaveBeenCalledOnce() + expect(abortB).toHaveBeenCalledOnce() + expect(oldAuthoritiesWereCurrent).toBe(false) + expect(isCurrentSshProviderAuthority(authorityA)).toBe(false) + expect(isCurrentSshProviderAuthority(authorityB)).toBe(false) + }) + + it('rejects old-authority registration reentered from an abort callback', () => { + const oldAuthority = getSshProviderAuthority('ssh-a') + const controllerA = new AbortController() + const controllerB = new AbortController() + const abortA = vi.fn() + let oldAuthorityWasCurrent = true + + controllerA.signal.addEventListener('abort', () => { + abortA() + oldAuthorityWasCurrent = isCurrentSshProviderAuthority(oldAuthority) + registerSshProviderRequestAbort(oldAuthority, controllerB) + }) + registerSshProviderRequestAbort(oldAuthority, controllerA) + + rotateSshProviderAuthority('ssh-a') + rotateSshProviderAuthority('ssh-a') + + expect(abortA).toHaveBeenCalledOnce() + expect(oldAuthorityWasCurrent).toBe(false) + expect(controllerB.signal.aborted).toBe(false) + }) + + it('removes settled and explicitly aborted registrations before later rotation', () => { + const authority = getSshProviderAuthority('ssh-a') + const settled = new AbortController() + const explicitlyAborted = new AbortController() + const settledAbort = vi.spyOn(settled, 'abort') + const explicitAbort = vi.spyOn(explicitlyAborted, 'abort') + const removeSettled = registerSshProviderRequestAbort(authority, settled) + registerSshProviderRequestAbort(authority, explicitlyAborted) + + removeSettled() + explicitlyAborted.abort() + rotateSshProviderAuthority('ssh-a') + + expect(settledAbort).not.toHaveBeenCalled() + expect(explicitAbort).toHaveBeenCalledOnce() + }) + + it('aborts and clears registrations during reset isolation', () => { + const oldAuthority = getSshProviderAuthority('ssh-a') + const oldController = new AbortController() + const oldAbort = vi.spyOn(oldController, 'abort') + registerSshProviderRequestAbort(oldAuthority, oldController) + + resetSshProviderAuthorities() + const newAuthority = getSshProviderAuthority('ssh-a') + const newController = new AbortController() + const newAbort = vi.spyOn(newController, 'abort') + registerSshProviderRequestAbort(newAuthority, newController) + rotateSshProviderAuthority('ssh-a') + + expect(oldAbort).toHaveBeenCalledOnce() + expect(newAbort).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ssh/ssh-provider-authority.ts b/src/main/ssh/ssh-provider-authority.ts new file mode 100644 index 00000000000..78eb3d54962 --- /dev/null +++ b/src/main/ssh/ssh-provider-authority.ts @@ -0,0 +1,148 @@ +import { randomBytes } from 'node:crypto' +import type { DirectSshAuthority, SshProviderEpoch } from '../../shared/ssh-types' +import { + advanceSshConnectionGeneration, + getSshConnectionGeneration +} from './ssh-connection-generation' + +const authorityByTarget = new Map() +type ProviderRequestAbortRegistration = { + authority: DirectSshAuthority + controller: AbortController + onAbort: () => void +} +const providerRequestAbortsByTarget = new Map>() +let providerEpochSequence = 0 + +function issueSshProviderEpoch(): SshProviderEpoch { + if (providerEpochSequence >= Number.MAX_SAFE_INTEGER) { + throw new Error('SSH provider epoch sequence exhausted') + } + providerEpochSequence += 1 + return `${randomBytes(12).toString('hex')}-${providerEpochSequence.toString(36)}` as SshProviderEpoch +} + +function createAuthority(targetId: string, connectionGeneration: number): DirectSshAuthority { + return { + targetId, + providerEpoch: issueSshProviderEpoch(), + connectionGeneration + } +} + +function authoritiesEqual(left: DirectSshAuthority, right: DirectSshAuthority): boolean { + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +function removeProviderRequestAbort(registration: ProviderRequestAbortRegistration): void { + registration.controller.signal.removeEventListener('abort', registration.onAbort) + const registrations = providerRequestAbortsByTarget.get(registration.authority.targetId) + if (!registrations?.delete(registration)) { + return + } + if (registrations.size === 0) { + providerRequestAbortsByTarget.delete(registration.authority.targetId) + } +} + +function abortProviderRequestsForAuthority(authority: DirectSshAuthority): void { + const registrations = providerRequestAbortsByTarget.get(authority.targetId) + if (!registrations) { + return + } + const matching = [...registrations].filter((registration) => + authoritiesEqual(registration.authority, authority) + ) + for (const registration of matching) { + removeProviderRequestAbort(registration) + } + for (const registration of matching) { + if (!registration.controller.signal.aborted) { + registration.controller.abort() + } + } +} + +function abortAllProviderRequests(): void { + const registrations = [...providerRequestAbortsByTarget.values()].flatMap((entries) => [ + ...entries + ]) + providerRequestAbortsByTarget.clear() + for (const registration of registrations) { + registration.controller.signal.removeEventListener('abort', registration.onAbort) + } + for (const registration of registrations) { + if (!registration.controller.signal.aborted) { + registration.controller.abort() + } + } +} + +export function registerSshProviderRequestAbort( + authority: DirectSshAuthority, + controller: AbortController +): () => void { + if (controller.signal.aborted || !isCurrentSshProviderAuthority(authority)) { + return () => {} + } + const capturedAuthority = { ...authority } + const registration: ProviderRequestAbortRegistration = { + authority: capturedAuthority, + controller, + onAbort: () => removeProviderRequestAbort(registration) + } + const registrations = + providerRequestAbortsByTarget.get(authority.targetId) ?? + new Set() + registrations.add(registration) + providerRequestAbortsByTarget.set(authority.targetId, registrations) + controller.signal.addEventListener('abort', registration.onAbort, { once: true }) + return () => removeProviderRequestAbort(registration) +} + +export function getSshProviderAuthority(targetId: string): DirectSshAuthority { + const generation = getSshConnectionGeneration(targetId) + const current = authorityByTarget.get(targetId) + if (current?.connectionGeneration === generation) { + return current + } + const authority = createAuthority(targetId, generation) + authorityByTarget.set(targetId, authority) + return authority +} + +export function rotateSshProviderAuthority(targetId: string): DirectSshAuthority { + const previous = authorityByTarget.get(targetId) + const previousGeneration = getSshConnectionGeneration(targetId) + const nextGeneration = advanceSshConnectionGeneration(targetId) + const authority = createAuthority(targetId, nextGeneration) + if (nextGeneration !== previousGeneration + 1) { + authorityByTarget.clear() + authorityByTarget.set(targetId, authority) + abortAllProviderRequests() + return authority + } + authorityByTarget.set(targetId, authority) + if (previous) { + abortProviderRequestsForAuthority(previous) + } + return authority +} + +export function isCurrentSshProviderAuthority(authority: DirectSshAuthority): boolean { + const current = authorityByTarget.get(authority.targetId) + return ( + current !== undefined && + current.connectionGeneration === getSshConnectionGeneration(authority.targetId) && + authoritiesEqual(current, authority) + ) +} + +export function resetSshProviderAuthorities(): void { + abortAllProviderRequests() + authorityByTarget.clear() +} diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 6c9ce09244b..276eaac6500 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -80,7 +80,9 @@ describe('runRemoteOrcaCli', () => { } } }), - getActiveDispatchForIdentity: vi.fn(() => undefined) + getActiveDispatchForIdentity: vi.fn(() => undefined), + getCurrentRunForPane: vi.fn(() => undefined), + findActiveRemoteAttachmentForPane: vi.fn(() => undefined) } const runtime = { getRuntimeId: () => 'runtime-test', @@ -480,6 +482,51 @@ describe('runRemoteOrcaCli', () => { expect(payload.result.messages[0]?.subject).toBe('pong') }) + it('carries the remote pane key for an implicit orchestration check', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'check', '--all', '--json'], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_stale_ssh', + ORCA_PANE_KEY: 'tab_ssh:leaf_ssh' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.getCurrentRunForPane).toHaveBeenCalledWith('tab_ssh:leaf_ssh') + expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith( + 'term_stale_ssh', + 'tab_ssh:leaf_ssh' + ) + }) + + it('does not inherit a remote pane key for explicit legacy inspection', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'check', '--terminal', 'term_legacy_worker', '--all', '--json'], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_stale_ssh', + ORCA_PANE_KEY: 'tab_ssh:leaf_ssh' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.getCurrentRunForPane).not.toHaveBeenCalled() + expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith('term_legacy_worker', undefined) + }) + it('routes previously-unsupported commands through the full host CLI', async () => { const { runtime } = createRuntime() const child = createFakeChild() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 70c25943616..caf1edb0e4a 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -195,6 +195,7 @@ async function dispatchRemoteCli( case 'orchestration check': return await call(dispatcher, 'orchestration.check', { terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'), + terminalPaneKey: parsed.flags.has('terminal') ? undefined : env.ORCA_PANE_KEY || undefined, unread: parsed.flags.has('unread') ? true : undefined, all: parsed.flags.has('all') ? true : undefined, types: optionalRemoteCliString(parsed.flags, 'types'), diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 8ba088e3504..bbdd790efb6 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -90,6 +90,26 @@ describe('startup ordering', () => { expect(startIndex).toBeGreaterThan(attachIndex) }) + it('attaches renderer services before starting the TCC prompt watcher', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const attachIndex = source.indexOf('attachMainWindowServices(') + const tccNoticeIndex = source.indexOf('initTccPromptNotice(window', attachIndex) + const quitAbortStart = source.indexOf('onQuitAborted:') + const quitAbortEnd = source.indexOf('onRendererProcessGone:', quitAbortStart) + + expect(attachIndex).toBeGreaterThanOrEqual(0) + expect(tccNoticeIndex).toBeGreaterThan(attachIndex) + expect(source.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( + 'deferWatchUntilReadyToShow: true' + ) + expect(source.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') + expect(source).toContain("process.once('exit', stopTccPromptNotice)") + const willQuitStart = source.indexOf("app.on('will-quit'") + const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) + expect(source.slice(willQuitStart, windowAllClosedStart)).toContain('stopTccPromptNotice()') + expect(source.slice(0, willQuitStart)).not.toContain('stopTccPromptNoticeForQuit') + }) + it('starts the automation scheduler before headless serve reports ready', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const serveStart = source.indexOf('if (serveOptions) {') diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index 57e7e682a7a..d1cb5bfd2fb 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -5,10 +5,11 @@ import { describe, expect, it } from 'vitest' describe('serve desktop activation wiring', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - it('routes second-instance and app activation through one safety gate', () => { + it('routes second-instance and windowless app activation through one safety gate', () => { expect(source).toContain('createServeDesktopActivationGate({') expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') - expect(source).toContain("app.on('activate', requestDesktopActivation)") + expect(source).toContain('createMacAppActivationHandler({') + expect(source).toContain("app.on('activate', handleMacAppActivation)") expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') }) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 730dd5be96c..f2204c6c5ac 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -177,6 +177,9 @@ vi.mock('./local-builds/local-build-feed-server', () => ({ startLocalBuildFeed: startLocalBuildFeedMock })) +/** Mirrors AUTO_UPDATE_CHECK_INTERVAL_MS in updater.ts. */ +const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 + describe('updater', () => { beforeEach(() => { vi.resetModules() @@ -400,6 +403,179 @@ describe('updater', () => { } ) + it.runIf(process.platform === 'darwin')( + 'keeps the automatic check chain alive across a local build session', + async () => { + vi.useFakeTimers() + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' }) + return Promise.resolve(undefined) + }) + autoUpdaterMock.downloadUpdate.mockRejectedValue(new Error('local download failed')) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.advanceTimersByTimeAsync(0) + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'available', source: 'local' }) + ) + + // Why: assert through this window rather than the shared updater mock — a status only lands here + // when this module instance owns the check attempt, so sibling tests' timers can't fake a pass. + autoUpdaterMock.checkForUpdates.mockReset().mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-not-available') + return Promise.resolve(undefined) + }) + + // The scheduled release check fires while the local session still owns the feed, so it defers. + send.mockClear() + await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS) + expect(send).not.toHaveBeenCalledWith('updater:status', { state: 'not-available' }) + + // A failed local download ends the session and restores the release source. + downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + + send.mockClear() + await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS) + expect(send).toHaveBeenCalledWith('updater:status', { state: 'not-available' }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores release checks when an offered local build is dismissed', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' }) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu, dismissAvailableUpdate } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'available', source: 'local' }) + ) + }) + + dismissAvailableUpdate() + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + expect(send).toHaveBeenCalledWith('updater:status', { state: 'idle' }) + + autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(undefined) + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the local feed in force when a dismiss lands during a local build download', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' }) + return Promise.resolve(undefined) + }) + autoUpdaterMock.downloadUpdate.mockResolvedValue(undefined) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu, dismissAvailableUpdate, downloadUpdate } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'available', source: 'local' }) + ) + }) + + downloadUpdate() + dismissAvailableUpdate() + + expect(closeLocalBuildFeedMock).not.toHaveBeenCalled() + expect(autoUpdaterMock.allowDowngrade).toBe(true) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(true) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'http://127.0.0.1:1234/token/' + }) + } + ) + + it('leaves a dismissed release update on the release source', async () => { + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '2.0.0' }) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu, dismissAvailableUpdate } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu() + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'available', version: '2.0.0' }) + ) + }) + + dismissAvailableUpdate() + + // Why: a release dismissal is renderer-only state; main must not clear the offer it can still install. + expect(closeLocalBuildFeedMock).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalledWith('updater:status', { state: 'idle' }) + }) + it('deduplicates identical check errors from the event and rejected promise', async () => { autoUpdaterMock.checkForUpdates.mockImplementation(() => { autoUpdaterMock.emit('checking-for-update') diff --git a/src/main/updater.ts b/src/main/updater.ts index 34067e03b50..b0df2d5b22e 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -978,7 +978,10 @@ function scheduleAutomaticUpdateCheck(delayMs: number): void { } autoUpdateCheckTimer = setTimeout(() => { // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. - runBackgroundUpdateCheck() + if (!runBackgroundUpdateCheck()) { + // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. + scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } }, effectiveDelayMs) } @@ -1212,18 +1215,19 @@ function retryPrereleaseFallbackAfterMissingManifest( return true } +/** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ function runBackgroundUpdateCheck( nudgeId: string | null = getPersistedPendingUpdateNudgeId() -): void { +): boolean { if (activeUpdateSource === 'local' || localBuildSelectionInProgress) { - return + return false } if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { - return + return false } if (!app.isPackaged || is.dev) { sendStatus({ state: 'not-available' }) - return + return false } // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. activeUpdateNudgeId = nudgeId @@ -1255,6 +1259,7 @@ function runBackgroundUpdateCheck( } void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err) }) + return true } export function checkForUpdates(): void { @@ -1509,6 +1514,24 @@ export function dismissNudge(): void { } } +/** + * The user closed an offered update without taking it. For a local build that ends the session: + * nothing will consume the local feed now, so release checks must stop being deferred. + */ +export function dismissAvailableUpdate(): void { + if (activeUpdateSource !== 'local' || localBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the local feed and allowDowngrade. + if (currentStatus.state !== 'available') { + return + } + clearAvailableUpdateContext() + restoreReleaseUpdateSource() + // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. + sendStatus({ state: 'idle' }) +} + export function setupAutoUpdater( mainWindow: BrowserWindow, opts?: { diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 0a6d381d3c0..abf0b0964be 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getCmdExePath, + getRegExePath, getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript, @@ -42,6 +43,20 @@ describe('isWindowsBatchScript', () => { }) }) +describe('getRegExePath', () => { + it('falls back to a local absolute system path for unsafe roots', () => { + expect(getRegExePath({ SystemRoot: '' })).toBe('C:\\Windows\\System32\\reg.exe') + expect(getRegExePath({ SystemRoot: 'Windows' })).toBe('C:\\Windows\\System32\\reg.exe') + expect(getRegExePath({ SystemRoot: '\\\\server\\share' })).toBe( + 'C:\\Windows\\System32\\reg.exe' + ) + }) + + it('uses an absolute custom Windows root', () => { + expect(getRegExePath({ SystemRoot: 'D:\\Windows' })).toBe('D:\\Windows\\System32\\reg.exe') + }) +}) + describe('getSpawnArgsForWindows', () => { it('routes .cmd through cmd.exe with /d /c on win32', () => { const originalComSpec = process.env.ComSpec diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 264ed6e6604..4edc47b93a1 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process' -import { delimiter, join } from 'node:path' +import { delimiter, join, win32 } from 'node:path' import { existsSync } from 'node:fs' function execFileWithoutBlocking( @@ -31,6 +31,13 @@ export function getWhoamiExePath(): string { return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe` } +/** Absolute path because service-launched Electron can omit System32 from PATH. */ +export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string { + const systemRoot = env.SystemRoot?.trim() + const root = systemRoot && /^[a-z]:[\\/]/i.test(systemRoot) ? systemRoot : 'C:\\Windows' + return win32.join(root, 'System32', 'reg.exe') +} + /** * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1ae323607c6..833f158fc56 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,7 +17,11 @@ const { hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, browserManagerUnregisterAllMock, - runWorktreeChangeInvalidatorsMock + runWorktreeChangeInvalidatorsMock, + acknowledgePendingTccPromptNoticeMock, + consumePendingTccPromptNoticeMock, + dismissTccPromptNoticeMock, + releasePendingTccPromptNoticeMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -34,7 +38,11 @@ const { hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), browserManagerUnregisterAllMock: vi.fn(), - runWorktreeChangeInvalidatorsMock: vi.fn() + runWorktreeChangeInvalidatorsMock: vi.fn(), + acknowledgePendingTccPromptNoticeMock: vi.fn(), + consumePendingTccPromptNoticeMock: vi.fn(), + dismissTccPromptNoticeMock: vi.fn(), + releasePendingTccPromptNoticeMock: vi.fn() })) vi.mock('electron', () => ({ @@ -92,6 +100,13 @@ vi.mock('../updater', () => ({ setupAutoUpdater: setupAutoUpdaterMock })) +vi.mock('../macos-tcc-prompt-notice', () => ({ + acknowledgePendingTccPromptNotice: acknowledgePendingTccPromptNoticeMock, + consumePendingTccPromptNotice: consumePendingTccPromptNoticeMock, + dismissTccPromptNotice: dismissTccPromptNoticeMock, + releasePendingTccPromptNotice: releasePendingTccPromptNoticeMock +})) + import { attachMainWindowServices } from './attach-main-window-services' type MockFn = ReturnType @@ -104,6 +119,7 @@ type MainWindowStub = { webContents: { id?: number isDestroyed?: MockFn + isLoadingMainFrame: MockFn on: MockFn send?: MockFn reload?: MockFn @@ -121,7 +137,9 @@ type RuntimeStub = { markGraphUnavailable: MockFn } -function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}): MainWindowStub { +function createMainWindow( + extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} +): MainWindowStub { return { id: 1, isDestroyed: vi.fn(() => false), @@ -130,6 +148,7 @@ function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {}) webContents: { id: 1, isDestroyed: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => true), on: vi.fn(), reload: vi.fn(), session: { @@ -175,7 +194,9 @@ async function fireReadyToShow(mainWindow: MainWindowStub): Promise { | (() => void) | undefined handler?.() - await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => { + setImmediate(resolve) + }) } describe('attachMainWindowServices', () => { @@ -195,6 +216,10 @@ describe('attachMainWindowServices', () => { hydrateLocalPtyRegistryAtBootMock.mockReset() setupAutoUpdaterMock.mockReset() browserManagerUnregisterAllMock.mockReset() + acknowledgePendingTccPromptNoticeMock.mockReset() + consumePendingTccPromptNoticeMock.mockReset() + dismissTccPromptNoticeMock.mockReset() + releasePendingTccPromptNoticeMock.mockReset() systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) systemPreferencesGetMediaAccessStatusMock.mockReturnValue('granted') }) @@ -289,6 +314,138 @@ describe('attachMainWindowServices', () => { expect(store.flush).toHaveBeenCalledTimes(1) }) + it('replaces the TCC handlers when the main window is reattached', () => { + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + const releaseCount = releasePendingTccPromptNoticeMock.mock.calls.length + attachMainWindowServices(createMainWindow() as never, createStore(), createRuntime() as never) + + for (const channel of [ + 'macosTccPrompts:consumePending', + 'macosTccPrompts:acknowledgePending', + 'macosTccPrompts:releasePending', + 'macosTccPrompts:dismiss' + ]) { + expect(removeHandlerMock.mock.calls.filter(([value]) => value === channel)).toHaveLength(2) + expect(handleMock.mock.calls.filter(([value]) => value === channel)).toHaveLength(2) + } + expect(releasePendingTccPromptNoticeMock).toHaveBeenCalledTimes(releaseCount + 1) + }) + + it('lets only the current main renderer consume the pending TCC notice', () => { + const mainWindow = createMainWindow() + consumePendingTccPromptNoticeMock.mockReturnValue({ claimId: 1, promptCount: 3 }) + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'macosTccPrompts:consumePending' + )?.[1] + expect(handler?.({ sender: { id: 999 } })).toBeNull() + expect(consumePendingTccPromptNoticeMock).not.toHaveBeenCalled() + expect(handler?.({ sender: mainWindow.webContents })).toEqual({ claimId: 1, promptCount: 3 }) + expect(consumePendingTccPromptNoticeMock).toHaveBeenCalledWith(expect.any(Number)) + }) + + it('acknowledges a claim only from the current main renderer', () => { + const mainWindow = createMainWindow() + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'macosTccPrompts:acknowledgePending' + )?.[1] + handler?.({ sender: { id: 999 } }, 7) + handler?.({ sender: mainWindow.webContents }, Number.NaN) + expect(acknowledgePendingTccPromptNoticeMock).not.toHaveBeenCalled() + + handler?.({ sender: mainWindow.webContents }, 7) + expect(acknowledgePendingTccPromptNoticeMock).toHaveBeenCalledWith(expect.any(Number), 7) + }) + + it('releases a claim only from the current main renderer', () => { + const mainWindow = createMainWindow() + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + releasePendingTccPromptNoticeMock.mockClear() + + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'macosTccPrompts:releasePending' + )?.[1] + handler?.({ sender: { id: 999 } }, 7) + handler?.({ sender: mainWindow.webContents }, Number.NaN) + expect(releasePendingTccPromptNoticeMock).not.toHaveBeenCalled() + + handler?.({ sender: mainWindow.webContents }, 7) + expect(releasePendingTccPromptNoticeMock).toHaveBeenCalledWith(expect.any(Number), 7) + }) + + it('releases the owner claim when the main renderer reloads or crashes', () => { + const mainWindow = createMainWindow() + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + const handlers = (event: string): (() => void)[] => + mainWindow.webContents.on.mock.calls + .filter(([name]) => name === event) + .map(([, handler]) => handler as () => void) + + releasePendingTccPromptNoticeMock.mockClear() + mainWindow.webContents.isLoadingMainFrame.mockReturnValue(false) + for (const handler of handlers('did-start-loading')) { + handler() + } + expect(releasePendingTccPromptNoticeMock).not.toHaveBeenCalled() + + mainWindow.webContents.isLoadingMainFrame.mockReturnValue(true) + for (const handler of handlers('did-start-loading')) { + handler() + } + expect(releasePendingTccPromptNoticeMock).toHaveBeenCalledOnce() + + releasePendingTccPromptNoticeMock.mockClear() + for (const handler of handlers('render-process-gone')) { + handler() + } + expect(releasePendingTccPromptNoticeMock).toHaveBeenCalledOnce() + }) + + it('removes the TCC handlers when the owning window closes', () => { + const mainWindow = createMainWindow() + attachMainWindowServices(mainWindow as never, createStore(), createRuntime() as never) + + removeHandlerMock.mockClear() + releasePendingTccPromptNoticeMock.mockClear() + for (const handler of getClosedHandlers(mainWindow.on)) { + handler() + } + + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:consumePending') + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:acknowledgePending') + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:releasePending') + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:dismiss') + expect(releasePendingTccPromptNoticeMock).toHaveBeenCalledOnce() + }) + + it('keeps newer TCC handlers when an older window closes late', () => { + const oldWindow = createMainWindow() + attachMainWindowServices(oldWindow as never, createStore(), createRuntime() as never) + const oldClosedHandlers = getClosedHandlers(oldWindow.on) + const newWindow = createMainWindow() + attachMainWindowServices(newWindow as never, createStore(), createRuntime() as never) + + removeHandlerMock.mockClear() + for (const handler of oldClosedHandlers) { + handler() + } + expect(removeHandlerMock).not.toHaveBeenCalledWith('macosTccPrompts:consumePending') + expect(removeHandlerMock).not.toHaveBeenCalledWith('macosTccPrompts:acknowledgePending') + expect(removeHandlerMock).not.toHaveBeenCalledWith('macosTccPrompts:releasePending') + expect(removeHandlerMock).not.toHaveBeenCalledWith('macosTccPrompts:dismiss') + + for (const handler of getClosedHandlers(newWindow.on)) { + handler() + } + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:consumePending') + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:acknowledgePending') + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:releasePending') + expect(removeHandlerMock).toHaveBeenCalledWith('macosTccPrompts:dismiss') + }) + it('ignores app reload requests from non-main webContents', async () => { const onBeforeRendererReload = vi.fn() const mainWindow = createMainWindow() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 5384d11c16a..21a5654e587 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -2,13 +2,19 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult, UpdateCheckOptions, WorktreeStartupLaunch } from '../../shared/types' +import { + acknowledgePendingTccPromptNotice, + consumePendingTccPromptNotice, + dismissTccPromptNotice, + releasePendingTccPromptNotice +} from '../macos-tcc-prompt-notice' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' @@ -30,6 +36,7 @@ import { getUpdateStatus, quitAndInstall, setupAutoUpdater, + dismissAvailableUpdate, dismissNudge, type UpdateInstallMode } from '../updater' @@ -64,6 +71,8 @@ export function ensureAutoUpdaterConfigured(): void { let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null +let tccPromptHandlerTokenCounter = 0 +let activeTccPromptHandlerToken: number | null = null let runtimeNotifierTokenCounter = 0 let activeRuntimeNotifierToken: number | null = null @@ -132,6 +141,7 @@ export function attachMainWindowServices( registerSshHandlers(store, () => mainWindow, runtime) registerRemoteWorkspaceHandlers(store, () => mainWindow) registerFileDropRelay(mainWindow) + registerTccPromptNoticeHandlers(mainWindow) // Why: setupAutoUpdater sync-require()s electron-updater (slow on cold Windows w/ Defender, #7225), so defer past first paint; timer fallback covers crash-looping renderers. let updaterSetupDone = false const setupAutoUpdaterDeferred = (): void => { @@ -202,6 +212,63 @@ export function attachMainWindowServices( }) } +function registerTccPromptNoticeHandlers(mainWindow: BrowserWindow): void { + const handlerToken = ++tccPromptHandlerTokenCounter + if (activeTccPromptHandlerToken !== null) { + releasePendingTccPromptNotice(activeTccPromptHandlerToken) + } + activeTccPromptHandlerToken = handlerToken + const consumeChannel = 'macosTccPrompts:consumePending' + const acknowledgeChannel = 'macosTccPrompts:acknowledgePending' + const releaseChannel = 'macosTccPrompts:releasePending' + const dismissChannel = 'macosTccPrompts:dismiss' + ipcMain.removeHandler(consumeChannel) + ipcMain.removeHandler(acknowledgeChannel) + ipcMain.removeHandler(releaseChannel) + ipcMain.removeHandler(dismissChannel) + const mainWebContents = mainWindow.webContents + const releaseOwnerClaim = (): void => releasePendingTccPromptNotice(handlerToken) + // Why: a renderer reload/crash destroys its claim callbacks without closing the BrowserWindow. + mainWebContents.on('did-start-loading', () => { + if (mainWebContents.isLoadingMainFrame()) { + releaseOwnerClaim() + } + }) + mainWebContents.on('render-process-gone', releaseOwnerClaim) + const ownsNotice = (event: IpcMainInvokeEvent): boolean => + !mainWindow.isDestroyed() && !mainWebContents.isDestroyed() && event.sender === mainWebContents + ipcMain.handle(consumeChannel, (event) => + ownsNotice(event) ? consumePendingTccPromptNotice(handlerToken) : null + ) + ipcMain.handle(acknowledgeChannel, (event, claimId: number) => { + if (ownsNotice(event) && Number.isSafeInteger(claimId)) { + acknowledgePendingTccPromptNotice(handlerToken, claimId) + } + }) + ipcMain.handle(releaseChannel, (event, claimId: number) => { + if (ownsNotice(event) && Number.isSafeInteger(claimId)) { + releasePendingTccPromptNotice(handlerToken, claimId) + } + }) + ipcMain.handle(dismissChannel, (event) => { + if (ownsNotice(event)) { + dismissTccPromptNotice() + } + }) + // Why: macOS can stay windowless; drop stale closures without letting an old close clear newer handlers. + mainWindow.on('closed', () => { + if (activeTccPromptHandlerToken !== handlerToken) { + return + } + releaseOwnerClaim() + ipcMain.removeHandler(consumeChannel) + ipcMain.removeHandler(acknowledgeChannel) + ipcMain.removeHandler(releaseChannel) + ipcMain.removeHandler(dismissChannel) + activeTccPromptHandlerToken = null + }) +} + function registerAppReloadHandler( mainWindow: BrowserWindow, onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void @@ -426,6 +493,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.removeHandler('updater:download') ipcMain.removeHandler('updater:quitAndInstall') ipcMain.removeHandler('updater:dismissNudge') + ipcMain.removeHandler('updater:dismissAvailableUpdate') ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) @@ -436,4 +504,5 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:download', () => downloadUpdate()) ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) ipcMain.handle('updater:dismissNudge', () => dismissNudge()) + ipcMain.handle('updater:dismissAvailableUpdate', () => dismissAvailableUpdate()) } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 1e01e55974d..013711e9e38 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -945,6 +945,82 @@ describe('createMainWindow', () => { expect(webContents.send).toHaveBeenCalledWith('ui:jumpToTabIndex', 4) }) + // While the floating panel owns the keyboard, L1 yields the initial indexed-switch keydown to the + // renderer (no preventDefault, no dispatch) so L2 selects a floating tab, and it contains held-key + // repeats in main (preventDefault, no dispatch) since the renderer skips e.repeat. + it('yields indexed-switch chords to the floating panel and contains their repeats', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const setFloatingFocus = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'ui:setFloatingFocus')?.[1] + expect(setFloatingFocus).toBeTypeOf('function') + setFloatingFocus?.( + { sender: webContents } as never, + { panelFocused: true, terminalFocused: false } as never + ) + + const beforeInputEvent = windowHandlers['before-input-event'] + const isDarwin = process.platform === 'darwin' + // jumpToTabIndex chord (Ctrl+digit on mac, Alt+digit elsewhere) and jumpToWorktreeIndex chord + // (Mod+digit) both yield while the panel owns focus. + const tabIndexInput = isDarwin + ? { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: true, alt: false } + : { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: false, alt: true } + const worktreeIndexInput = isDarwin + ? { type: 'keyDown', code: 'Digit5', key: '5', meta: true, control: false, alt: false } + : { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: true, alt: false } + + for (const input of [tabIndexInput, worktreeIndexInput]) { + // Initial (non-repeat) keydown: yielded to the renderer — neither prevented nor dispatched. + const yieldPreventDefault = vi.fn() + beforeInputEvent({ preventDefault: yieldPreventDefault } as never, input as never) + expect(yieldPreventDefault).not.toHaveBeenCalled() + + // Held-key repeat: contained in main — prevented, still not dispatched. + const repeatPreventDefault = vi.fn() + beforeInputEvent( + { preventDefault: repeatPreventDefault } as never, + { ...input, isAutoRepeat: true } as never + ) + expect(repeatPreventDefault).toHaveBeenCalledTimes(1) + } + + expect(webContents.send).not.toHaveBeenCalledWith('ui:jumpToTabIndex', expect.anything()) + expect(webContents.send).not.toHaveBeenCalledWith('ui:jumpToWorktreeIndex', expect.anything()) + }) + it('lets main-window Ctrl+Tab flow to the renderer held switcher', () => { const windowHandlers: Record void> = {} const webContents = { @@ -2498,9 +2574,12 @@ describe('createMainWindow', () => { const setFocusedListener = vi .mocked(ipcMain.on) - .mock.calls.find(([channel]) => channel === 'ui:setFloatingTerminalInputFocused')?.[1] + .mock.calls.find(([channel]) => channel === 'ui:setFloatingFocus')?.[1] expect(setFocusedListener).toBeTypeOf('function') - setFocusedListener?.({ sender: webContents } as never, true) + setFocusedListener?.( + { sender: webContents } as never, + { panelFocused: true, terminalFocused: true } as never + ) const preventDefault = vi.fn() const isDarwin = process.platform === 'darwin' diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 909ed473e86..ffed4d013c2 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -482,7 +482,9 @@ export function createMainWindow( // Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md). let markdownEditorFocused = false let terminalInputFocused = false + // floatingTerminalInputFocused: textarea-only (terminal keybinding context). floatingPanelFocused: superset for routing ownership. let floatingTerminalInputFocused = false + let floatingPanelFocused = false let shortcutRecorderFocused = false const markdownFocusChannel = 'ui:setMarkdownEditorFocused' @@ -503,15 +505,20 @@ export function createMainWindow( terminalInputFocused = focused === true } ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused) - const floatingTerminalInputFocusChannel = 'ui:setFloatingTerminalInputFocused' - // Why: before-input-event runs before renderer keydown; mirror floating xterm focus so Ctrl+B/L reach SSH/tmux. - const onFloatingTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => { + const floatingFocusChannel = 'ui:setFloatingFocus' + // Why: one atomic payload for both bits so before-input-event never reads a torn terminal=true/panel=false state. + // terminalFocused drives the Ctrl+B/L terminal-context carve-out; panelFocused is the routing-ownership superset (panel ⊇ terminal). + const onFloatingFocus = (event: Electron.IpcMainEvent, state: unknown): void => { if (event.sender !== mainWindow.webContents) { return } - floatingTerminalInputFocused = focused === true + const payload = (state ?? {}) as { panelFocused?: unknown; terminalFocused?: unknown } + const terminal = payload.terminalFocused === true + floatingTerminalInputFocused = terminal + // Re-assert the invariant defensively in case a sender ever emits panel=false with terminal=true. + floatingPanelFocused = payload.panelFocused === true || terminal } - ipcMain.on(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) + ipcMain.on(floatingFocusChannel, onFloatingFocus) const shortcutRecorderFocusChannel = 'ui:setShortcutRecorderFocused' // Why: the Settings recorder must receive app shortcuts to rebind them; before-input-event would otherwise consume the key first. const onShortcutRecorderFocused = (event: Electron.IpcMainEvent, focused: unknown): void => { @@ -541,6 +548,7 @@ export function createMainWindow( } const resetFloatingTerminalInputFocus = (): void => { floatingTerminalInputFocused = false + floatingPanelFocused = false } const resetShortcutRecorderFocus = (): void => { shortcutRecorderFocused = false @@ -710,6 +718,20 @@ export function createMainWindow( return false } + // While the floating panel owns the keyboard, yield indexed switch chords to the renderer + // so L2 selects a floating tab instead of switching the main workspace behind the panel. + if ( + floatingPanelFocused && + (action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex') + ) { + if (isAutoRepeat) { + // Contain held-key repeats in main — both renderer index paths skip e.repeat, so yielding a repeat would leak a raw key to xterm/DOM. + event.preventDefault() + return true + } + return false + } + const capturedTerminalActionId = focusedShortcutContext.context === 'terminal' && focusedShortcutContext.terminalShortcutPolicy === 'orca-first' && @@ -1085,6 +1107,7 @@ export function createMainWindow( markdownEditorFocused = false terminalInputFocused = false floatingTerminalInputFocused = false + floatingPanelFocused = false shortcutRecorderFocused = false clearRendererRecoveryTimer() ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) @@ -1098,7 +1121,7 @@ export function createMainWindow( ipcMain.removeListener(closeRequestReceivedChannel, onCloseRequestReceived) ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused) ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) - ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) + ipcMain.removeListener(floatingFocusChannel, onFloatingFocus) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) // Why: powerMonitor is app-global; without this the resume relay leaks and fires against a destroyed webContents. powerMonitor.removeListener('resume', onSystemResume) diff --git a/src/main/window/focus-existing-window.test.ts b/src/main/window/focus-existing-window.test.ts index e12e71ae109..85b422e02a4 100644 --- a/src/main/window/focus-existing-window.test.ts +++ b/src/main/window/focus-existing-window.test.ts @@ -110,19 +110,21 @@ describe('focusExistingMainWindow', () => { it('restores minimized windows before focusing them', () => { const window = makeFakeWindow({ minimized: true }) + const timer = makeTimer() focusExistingMainWindow({ app: makeFakeApp(), getWindow: () => window, openWindow: vi.fn(), platform: 'darwin', - setTimeout: makeTimer().setTimeout + setTimeout: timer.setTimeout }) expect(window.calls.restore).toHaveBeenCalledTimes(1) expect(window.calls.show).toHaveBeenCalledTimes(1) expect(window.calls.focus).toHaveBeenCalledTimes(1) expect(window.calls.moveTop).not.toHaveBeenCalled() + expect(timer.scheduledMs()).toEqual([]) }) it('waits for normal startup when no window exists before app readiness', () => { diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 3ba740cd2df..659f7394887 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -81,8 +81,8 @@ function activateWindow( // Older Electron versions or destroyed windows may reject this; focus retry remains. } pulseAlwaysOnTop(window, setTimer) + retryFocus(window, app, setTimer) } - retryFocus(window, app, setTimer) } // Why: a second-instance/activate reopen can race transient startup pressure diff --git a/src/main/window/macos-app-activation.test.ts b/src/main/window/macos-app-activation.test.ts new file mode 100644 index 00000000000..e4670422cb4 --- /dev/null +++ b/src/main/window/macos-app-activation.test.ts @@ -0,0 +1,38 @@ +import type { BrowserWindow } from 'electron' +import { describe, expect, it, vi } from 'vitest' +import { createMacAppActivationHandler } from './macos-app-activation' + +function makeWindow(destroyed = false): BrowserWindow { + return { + isDestroyed: vi.fn(() => destroyed) + } as unknown as BrowserWindow +} + +describe('createMacAppActivationHandler', () => { + it('leaves an existing window to native macOS activation', () => { + const requestActivation = vi.fn() + const handler = createMacAppActivationHandler({ + getWindow: () => makeWindow(), + requestActivation + }) + + handler() + + expect(requestActivation).not.toHaveBeenCalled() + }) + + it.each([null, makeWindow(true)])( + 'requests desktop activation for a missing or destroyed window', + (window) => { + const requestActivation = vi.fn() + const handler = createMacAppActivationHandler({ + getWindow: () => window, + requestActivation + }) + + handler() + + expect(requestActivation).toHaveBeenCalledTimes(1) + } + ) +}) diff --git a/src/main/window/macos-app-activation.ts b/src/main/window/macos-app-activation.ts new file mode 100644 index 00000000000..42b309cf861 --- /dev/null +++ b/src/main/window/macos-app-activation.ts @@ -0,0 +1,14 @@ +import type { BrowserWindow } from 'electron' + +export function createMacAppActivationHandler(options: { + getWindow: () => BrowserWindow | null + requestActivation: () => void +}): () => void { + return () => { + const window = options.getWindow() + // Why: re-focusing an existing macOS window can race its scene-backed Space transition. + if (!window || window.isDestroyed()) { + options.requestActivation() + } + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a9218f0c457..b5b053c1ea2 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -26,13 +26,35 @@ import type { } from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { + HostQualifiedDetectedWorktreeResult, + LegacyDetectedWorktreeRequest, + ListDetectedWorktreesArgs, + ProviderRequestId +} from '../shared/detected-worktree-provider-contract' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../shared/host-repo-catalog-contract' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../shared/host-lineage-contract' import type { WriteTerminalRenderDesyncEvidenceArgs, WriteTerminalRenderDesyncEvidenceResult } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { SshMutationExpectation } from '../shared/ssh-types' +import type { + SshMutationExpectation, + SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, + SshTarget, + PortForwardEntry, + EnrichedDetectedPort +} from '../shared/ssh-types' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -260,10 +282,8 @@ import type { import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { - EphemeralVmRecipeDoctorResult, - EphemeralVmRecipeResultWarning -} from '../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeDoctorResult } from '../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeResultWarning } from '../shared/ephemeral-vm-recipe-diagnostics' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' @@ -431,14 +451,6 @@ import type { WorkspacePortScanResult } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { - SshConnectionState, - SshConfigImportResult, - SshTargetAddResult, - SshTarget, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, @@ -1159,6 +1171,7 @@ export type PreloadApi = { } repos: { list: () => Promise + listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. add: (args: { path: string @@ -1344,7 +1357,13 @@ export type PreloadApi = { } worktrees: { list: (args: { repoId: string }) => Promise - listDetected: (args: { repoId: string }) => Promise + listDetected: { + ( + args: ListDetectedWorktreesArgs + ): Promise + (args: LegacyDetectedWorktreeRequest): Promise + } + cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise /** Two-phase progress for a background `create`, correlated by `creationId`. The remote/runtime @@ -1394,6 +1413,7 @@ export type PreloadApi = { lineage: Record workspaceLineage?: Record }> + listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise updateLineage: (args: { worktreeId: string parentWorktreeId?: string @@ -2342,6 +2362,8 @@ export type PreloadApi = { }) => Promise< { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] > + /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ + listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> /** Drops launch records so a dismissed prompt stays dismissed across restarts. */ forgetStalePanes: (args: { ptyIds: string[] }) => Promise } @@ -2440,6 +2462,14 @@ export type PreloadApi = { unsubscribe: (ptyId: string) => Promise onData: (callback: (payload: TerminalPreviewDataPayload) => void) => () => void } + macosTccPrompts: { + /** Fires once macOS has raised its Nth consent dialog naming Orca (#9756). */ + onThreshold: (callback: (payload: { promptCount: number }) => void) => () => void + consumePending: () => Promise<{ claimId: number; promptCount: number } | null> + acknowledgePending: (claimId: number) => Promise + releasePending: (claimId: number) => Promise + dismiss: () => Promise + } developerPermissions: { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise @@ -2618,6 +2648,7 @@ export type PreloadApi = { download: () => Promise quitAndInstall: () => Promise dismissNudge: () => Promise + dismissAvailableUpdate: () => Promise onStatus: (callback: (status: UpdateStatus) => void) => () => void onClearDismissal: (callback: () => void) => () => void } @@ -3066,6 +3097,8 @@ export type PreloadApi = { onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void onHardReloadBrowserPage: (callback: () => void) => () => void onCloseActiveTab: (callback: () => void) => () => void + onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void) => () => void + onSelectFloatingIndex: (callback: (payload: { index: number }) => void) => () => void onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void onSwitchRecentTab: (callback: () => void) => () => void @@ -3206,7 +3239,7 @@ export type PreloadApi = { syncTrafficLights: (zoomFactor: number) => void setMarkdownEditorFocused: (focused: boolean) => void setTerminalInputFocused: (focused: boolean) => void - setFloatingTerminalInputFocused: (focused: boolean) => void + setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }) => void setShortcutRecorderFocused: (focused: boolean) => void onRichMarkdownContextCommand: ( callback: (payload: RichMarkdownContextMenuCommandPayload) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c28865b1e1..9141e9c43bc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,7 +21,27 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { SshMutationExpectation } from '../shared/ssh-types' +import type { + SshMutationExpectation, + SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, + SshTarget, + PortForwardEntry, + EnrichedDetectedPort +} from '../shared/ssh-types' +import { + admitSshConnectionStateForAuthorityReconciliation, + admitSshDetectedPorts +} from '../shared/ssh-retained-payload-admission' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../shared/host-repo-catalog-contract' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../shared/host-lineage-contract' import type { PluginPanelActionOutcome, PluginPanelEntry @@ -158,14 +178,6 @@ import { richMarkdownContextMenuCommandChannel, type RichMarkdownContextMenuCommandPayload } from '../shared/rich-markdown-context-menu' -import type { - SshConnectionState, - SshConfigImportResult, - SshTargetAddResult, - SshTarget, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload, @@ -182,7 +194,18 @@ import type { SpeechTranscriptEvent } from '../shared/speech-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { PreflightRuntimeContext, RefreshAgentsResult } from './api-types' +import type { + PreflightRuntimeContext, + RefreshAgentsResult, + NativeChatAppendedPayload, + NativeChatReadSessionResult, + NativeChatSubscriptionFrame, + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine, + PreloadApi +} from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' @@ -205,11 +228,6 @@ import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybi import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' -import type { - NativeChatAppendedPayload, - NativeChatReadSessionResult, - NativeChatSubscriptionFrame -} from './api-types' import { ORCA_APP_RESTART_ABORTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, @@ -252,13 +270,6 @@ import type { } from '../shared/crash-reporting' import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import type { - PluginHostInstallResult, - PluginHostInstallSource, - PluginHostListEntry, - PluginHostLogLine, - PreloadApi -} from './api-types' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart @@ -592,6 +603,9 @@ const api = { repos: { list: () => ipcRenderer.invoke('repos:list'), + listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => + ipcRenderer.invoke('repos:listForExecutionHost', args), + add: (args) => ipcRenderer.invoke('repos:add', args), addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), @@ -725,6 +739,8 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + listAll: () => ipcRenderer.invoke('worktrees:listAll'), create: (args) => ipcRenderer.invoke('worktrees:create', args), @@ -757,6 +773,9 @@ const api = { listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), + listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => + ipcRenderer.invoke('worktrees:listLineageForHost', args), + updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), @@ -1999,6 +2018,8 @@ const api = { }): Promise< { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), + listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => + ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) }, @@ -2278,6 +2299,22 @@ const api = { } }, + macosTccPrompts: { + onThreshold: (callback: (payload: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => + callback(payload) + ipcRenderer.on('macosTccPrompts:threshold', listener) + return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + }, + consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => + ipcRenderer.invoke('macosTccPrompts:consumePending'), + acknowledgePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), + releasePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), + dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') + }, + developerPermissions: { getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), request: (args: { id: string }): Promise => @@ -2882,6 +2919,7 @@ const api = { check: (options) => ipcRenderer.invoke('updater:check', options), download: () => ipcRenderer.invoke('updater:download'), dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), + dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), quitAndInstall: async (): Promise => { await prepareRendererForAppRestart(window, { startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, @@ -3547,6 +3585,18 @@ const api = { ipcRenderer.on('ui:closeActiveTab', listener) return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) }, + onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => + callback(payload) + ipcRenderer.on('ui:closeFloatingItem', listener) + return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) + }, + onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => + callback(payload) + ipcRenderer.on('ui:selectFloatingIndex', listener) + return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) + }, onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) ipcRenderer.on('ui:switchTab', listener) @@ -3926,8 +3976,9 @@ const api = { setTerminalInputFocused: (focused: boolean): void => { ipcRenderer.send('ui:setTerminalInputFocused', focused) }, - setFloatingTerminalInputFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setFloatingTerminalInputFocused', focused) + // Why: one atomic payload so main's synchronous before-input-event never sees a torn terminal=true/panel=false state. + setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { + ipcRenderer.send('ui:setFloatingFocus', state) }, setShortcutRecorderFocused: (focused: boolean): void => { ipcRenderer.send('ui:setShortcutRecorderFocused', focused) @@ -4297,8 +4348,10 @@ const api = { importConfig: (args?: { reAdopt?: boolean }): Promise => ipcRenderer.invoke('ssh:importConfig', args), - connect: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:connect', args), + connect: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:connect', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, disconnect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:disconnect', args), @@ -4309,24 +4362,37 @@ const api = { resetRelay: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:resetRelay', args), - getState: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:getState', args), + getState: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:getState', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, needsPassphrasePrompt: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), - testConnection: (args: { + testConnection: async (args: { targetId: string - }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => - ipcRenderer.invoke('ssh:testConnection', args), + }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { + const result: { success: boolean; error?: string; state?: unknown } = + await ipcRenderer.invoke('ssh:testConnection', args) + const state = result.state + ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) + : null + return { ...result, ...(state ? { state } : { state: undefined }) } + }, onStateChanged: ( callback: (data: { targetId: string; state: SshConnectionState }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { targetId: string; state: SshConnectionState } - ) => callback(data) + data: { targetId: string; state: unknown } + ): void => { + const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) + if (state) { + callback({ targetId: data.targetId, state }) + } + } ipcRenderer.on('ssh:state-changed', listener) return () => ipcRenderer.removeListener('ssh:state-changed', listener) }, @@ -4354,8 +4420,8 @@ const api = { listPortForwards: (args?: { targetId?: string }): Promise => ipcRenderer.invoke('ssh:listPortForwards', args), - listDetectedPorts: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:listDetectedPorts', args), + listDetectedPorts: async (args: { targetId: string }): Promise => + admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), onPortForwardsChanged: ( callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void @@ -4373,8 +4439,8 @@ const api = { ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, - data: { targetId: string; ports: EnrichedDetectedPort[] } - ) => callback(data) + data: { targetId: string; ports: unknown } + ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) ipcRenderer.on('ssh:detected-ports-changed', handler) return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) }, diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts new file mode 100644 index 00000000000..7a81df602d2 --- /dev/null +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' +import type { SshConnectionState, SshProviderEpoch } from '../shared/ssh-types' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../shared/detected-worktree-provider-contract' +import type { DetectedWorktreeListResult } from '../shared/types' + +function listDetectedVariableTypeProbe(api: PreloadApi, args: ListDetectedWorktreesArgs) { + return api.worktrees.listDetected(args) +} + +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener, send, sendSync }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +describe('native preload SSH authority forwarding', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + + beforeEach(() => { + vi.resetModules() + exposeInMainWorld.mockReset() + invoke.mockReset() + on.mockReset() + removeListener.mockReset() + send.mockReset() + sendSync.mockReset() + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + it('retains host-qualified outcomes for variable-form detected-worktree requests', () => { + expectTypeOf(listDetectedVariableTypeProbe).returns.toEqualTypeOf< + Promise + >() + }) + + it('forwards full-pair get and push states without cloning away authority', async () => { + const state: SshConnectionState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'native-provider-epoch' as SshProviderEpoch, + connectionGeneration: 29 + } + invoke.mockResolvedValueOnce(state) + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + + await expect(api.ssh.getState({ targetId: 'ssh-1' })).resolves.toEqual(state) + expect(invoke).toHaveBeenCalledWith('ssh:getState', { targetId: 'ssh-1' }) + + const onStateChanged = vi.fn() + api.ssh.onStateChanged(onStateChanged) + const listener = on.mock.calls.find(([channel]) => channel === 'ssh:state-changed')?.[1] as ( + event: unknown, + data: { targetId: string; state: SshConnectionState } + ) => void + listener({}, { targetId: 'ssh-1', state }) + + expect(onStateChanged).toHaveBeenCalledWith({ targetId: 'ssh-1', state }) + expect(onStateChanged.mock.calls[0]?.[0].state).toEqual(state) + }) + + it('normalizes partial compatibility authority to unknown for reconciliation', async () => { + const partialState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' + } as SshConnectionState + invoke.mockResolvedValueOnce(partialState) + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + + const returned = await api.ssh.getState({ targetId: 'ssh-1' }) + expect(returned).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: null + }) + + const onStateChanged = vi.fn() + api.ssh.onStateChanged(onStateChanged) + const listener = on.mock.calls.find(([channel]) => channel === 'ssh:state-changed')?.[1] as ( + event: unknown, + data: { targetId: string; state: SshConnectionState } + ) => void + listener({}, { targetId: 'ssh-1', state: partialState }) + + expect(onStateChanged.mock.calls[0]?.[0].state).toEqual({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: null + }) + }) + + it('drops malformed full authority before it reaches the renderer', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const onStateChanged = vi.fn() + api.ssh.onStateChanged(onStateChanged) + const listener = on.mock.calls.find(([channel]) => channel === 'ssh:state-changed')?.[1] as ( + event: unknown, + data: { targetId: string; state: SshConnectionState } + ) => void + + listener( + {}, + { + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: '' as SshProviderEpoch, + connectionGeneration: 29 + } + } + ) + + expect(onStateChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/agent-hook-server-codex-subagent-transcript.test.ts b/src/relay/agent-hook-server-codex-subagent-transcript.test.ts new file mode 100644 index 00000000000..e9d20d51db2 --- /dev/null +++ b/src/relay/agent-hook-server-codex-subagent-transcript.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { makePaneKey } from '../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' + +function line(record: unknown): string { + return `${JSON.stringify(record)}\n` +} + +describe('RelayAgentHookServer Codex subagent transcript polling', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('forwards completion discovered from a child rollout', async () => { + const dir = mkdtempSync(join(tmpdir(), 'relay-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: CHILD_ID, + agent_path: '/root/pr_review', + kind: 'started' + } + }) + ) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + + expect(response.status).toBe(204) + expect(forward.mock.calls[0]?.[0].payload.subagents).toHaveLength(1) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.waitFor( + () => { + expect(forward.mock.calls.at(-1)?.[0].payload.subagents).toBeUndefined() + expect(forward).toHaveBeenCalledTimes(2) + }, + { timeout: 2_000, interval: 50 } + ) + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 40775b5bd6a..fb938f55d2b 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -14,6 +14,7 @@ import { clearPaneCacheState, createHookListenerState, getEndpointFileName, + hasCodexTranscriptSubagents, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, @@ -37,6 +38,7 @@ const RELAY_HOOKS_DIR_NAME = '.orca-relay' const RELAY_HOOKS_SUBDIR = 'agent-hooks' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 +const CODEX_SUBAGENT_POLL_MS = 1_000 // Why: cap env/version at 64 chars so a misbehaving agent CLI can't grow the meta cache unboundedly; canonical values are short. const MAX_HOOK_META_LEN = 64 @@ -101,6 +103,7 @@ export class RelayAgentHookServer { { source: AgentHookSource; env?: string; version?: string } > = new Map() private assistantMessageRetryTimers = new Map>() + private codexSubagentPollTimers = new Map>() private forward: RelayHookForward private fixedToken: string | undefined private preferredPort: number @@ -193,6 +196,10 @@ export class RelayAgentHookServer { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() + for (const timer of this.codexSubagentPollTimers.values()) { + clearTimeout(timer) + } + this.codexSubagentPollTimers.clear() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() } @@ -216,6 +223,7 @@ export class RelayAgentHookServer { /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.lastEnvelopeMetaByPaneKey.delete(paneKey) } @@ -274,6 +282,7 @@ export class RelayAgentHookServer { const version = this.bodyVersion(body) this.applyEvent(event, source, env, version) this.scheduleAssistantMessageRetry(source, body, event, env, version) + this.scheduleCodexSubagentPoll(source, body, event, env, version) } res.writeHead(204) res.end() @@ -350,6 +359,53 @@ export class RelayAgentHookServer { this.assistantMessageRetryTimers.delete(paneKey) } + private clearCodexSubagentPoll(paneKey: string): void { + const timer = this.codexSubagentPollTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.codexSubagentPollTimers.delete(paneKey) + } + + private scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: AgentHookEventPayload, + env?: string, + version?: string + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.clearCodexSubagentPoll(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + const timer = setTimeout(() => { + this.codexSubagentPollTimers.delete(original.paneKey) + if (!this.server || this.state.lastStatusByPaneKey.get(original.paneKey) !== original) { + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (!event) { + return + } + const subagentsChanged = + JSON.stringify(event.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? event : original + if (subagentsChanged) { + this.applyEvent(event, source, env, version) + } + this.scheduleCodexSubagentPoll(source, body, next, env, version) + }, CODEX_SUBAGENT_POLL_MS) + this.codexSubagentPollTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + private scheduleAssistantMessageRetry( source: AgentHookSource, body: unknown, diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 4df0f7bd9b5..617ab84e679 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -286,7 +286,7 @@ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { const text = payload.toString('utf-8') const msg = JSON.parse(text) as JsonRpcMessage if (msg.jsonrpc !== '2.0') { - throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) + throw new Error(`Invalid JSON-RPC version: ${String((msg as Record).jsonrpc)}`) } return msg } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 267b9ee8b36..82110b58db2 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -342,7 +342,7 @@ async function main(): Promise { }) process.on('unhandledRejection', (reason) => { - relayLogLine(`[relay] Unhandled rejection: ${reason}`) + relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) }) // Why: guards writes after the stdin/SSH channel drops so keepalive/pty.data frames don't hit a dead pipe (EPIPE). diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bf860eadfc2..6ee3e79eaa9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -75,8 +75,8 @@ import { } from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, - isFloatingWorkspacePanelShortcut, isFloatingWorkspaceTerminalInputTarget, + matchFloatingWorkspacePanelChord, shouldMinimizeFloatingWorkspacePanelOnCloseShortcut } from '@/lib/floating-workspace-terminal-actions' import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' @@ -110,6 +110,7 @@ import { } from './runtime/sync-runtime-graph' import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' +import { MacosTccPromptNoticeHost } from './hooks/MacosTccPromptNoticeHost' import { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' import { @@ -179,6 +180,7 @@ import { keybindingMatchesAction, type KeybindingActionId, type KeybindingContext, + type KeybindingMatchOptions, type PhysicalModifierToken } from '../../shared/keybindings' import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../shared/plugins/plugin-command-actions' @@ -407,8 +409,11 @@ function applyRemoteWorkspacePatchStatus( message: result.message ?? (result.reason === 'stale-revision' - ? 'Workspace changed on another device' - : 'Remote workspace sync unavailable') + ? translate( + 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', + 'Workspace changed on another device' + ) + : translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable')) }) } @@ -657,6 +662,7 @@ function App(): React.JSX.Element { const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth) + const combinedDiffFileTreeWidth = useAppStore((s) => s.combinedDiffFileTreeWidth) const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView) @@ -1340,6 +1346,7 @@ function App(): React.JSX.Element { rightSidebarExplorerView, rightSidebarWidth, markdownTocPanelWidth, + combinedDiffFileTreeWidth, groupBy, sortBy, projectOrderBy, @@ -1374,6 +1381,7 @@ function App(): React.JSX.Element { rightSidebarExplorerView, rightSidebarWidth, markdownTocPanelWidth, + combinedDiffFileTreeWidth, groupBy, sortBy, projectOrderBy, @@ -1834,11 +1842,15 @@ function App(): React.JSX.Element { // Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel. const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() if (floatingWorkspaceFocused) { + const floatingMatchOptions: KeybindingMatchOptions = { context, terminalShortcutPolicy } if ( - isFloatingWorkspacePanelShortcut(input, shortcutPlatform, null, keybindings, { - context, - terminalShortcutPolicy - }) + matchFloatingWorkspacePanelChord( + input, + shortcutPlatform, + null, + keybindings, + floatingMatchOptions + ) !== null ) { return } @@ -2196,6 +2208,8 @@ function App(): React.JSX.Element { + {/* Why: plugin language-pack discovery must not re-render the App shell. */} + {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index a7d39b67af7..325712fd695 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1164,11 +1164,11 @@ background: color-mix(in srgb, var(--sidebar-accent) 40%, transparent); } -/* Why: mixing into the concrete sidebar surface keeps selected contrast stable, - including when a custom translucent sidebar theme is active. */ +/* Why: the brighter border carries the selected state; a translucent wash keeps + card text legible instead of lifting the surface toward the foreground color. */ [data-worktree-card-surface][data-worktree-card-active='primary'] { border-color: color-mix(in srgb, var(--worktree-sidebar-border) 40%, transparent); - background: color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, var(--worktree-sidebar)); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, transparent); box-shadow: 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 4%, transparent); } @@ -1178,7 +1178,7 @@ var(--worktree-sidebar-foreground) 18%, var(--worktree-sidebar-border) ); - background: color-mix(in srgb, var(--worktree-sidebar-foreground) 16%, var(--worktree-sidebar)); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 10%, transparent); box-shadow: 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 3%, transparent); } diff --git a/src/renderer/src/assets/worktree-card-active-style.test.ts b/src/renderer/src/assets/worktree-card-active-style.test.ts index 3d80531036b..814c7ae55ee 100644 --- a/src/renderer/src/assets/worktree-card-active-style.test.ts +++ b/src/renderer/src/assets/worktree-card-active-style.test.ts @@ -14,7 +14,7 @@ function getCssRuleBody(selector: string): string { } describe('worktree card active styling', () => { - it('mixes the primary selection into the worktree sidebar surface', () => { + it('keeps the primary selection wash translucent so card text stays legible', () => { const primary = getCssRuleBody( "[data-worktree-card-surface][data-worktree-card-active='primary']" ) @@ -22,9 +22,12 @@ describe('worktree card active styling', () => { ".dark [data-worktree-card-surface][data-worktree-card-active='primary']" ) - expect(primary).toContain('var(--worktree-sidebar-foreground) 8%') - expect(primary).toContain('var(--worktree-sidebar)') - expect(darkPrimary).toContain('var(--worktree-sidebar-foreground) 16%') + expect(primary).toContain( + 'background: color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, transparent)' + ) + expect(darkPrimary).toContain( + 'background: color-mix(in srgb, var(--worktree-sidebar-foreground) 10%, transparent)' + ) expect(darkPrimary).toContain('var(--worktree-sidebar-border)') }) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index ddb11435c4d..e7001fcc783 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React from 'react' -import { act } from 'react' +import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' @@ -281,15 +280,18 @@ function changeInputValue(input: HTMLInputElement, value: string): void { } function openRunTargetPicker(container: HTMLElement): void { - const runTargetButton = container.querySelector('button[role="combobox"]') - expect(runTargetButton).toBeTruthy() - act(() => runTargetButton?.click()) + // The field is the search box; clicking its shell focuses it and opens the list. + const runTargetShell = container.querySelector( + 'div[data-run-target-combobox-root="true"]' + ) + expect(runTargetShell).toBeTruthy() + act(() => runTargetShell?.click()) } function findRunTargetItem(label: string): HTMLElement | undefined { - // Why: "Add host" is a pinned footer button (mirrors the Project combobox), not a cmdk row. + // Rows are listbox options; "Add host" is the pinned footer row (also an option). return [ - ...document.body.querySelectorAll('[cmdk-item], [data-run-target-add-host]') + ...document.body.querySelectorAll('[role="option"], [data-run-target-add-host]') ].find((item) => item.textContent?.includes(label)) } @@ -580,11 +582,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { const devboxItem = findRunTargetItem('Devbox') expect(devboxItem?.textContent).toContain('Project not set up on this host') - // Not-connected rows stay highlightable (not disabled) so they hover like the other - // items; a separator sets them off instead of a heading. - expect(devboxItem?.getAttribute('aria-disabled')).toBe('false') - expect(devboxItem?.getAttribute('data-disabled')).toBe('false') - expect(document.body.querySelector('[cmdk-separator]')).toBeTruthy() + // Not-connected rows stay highlightable (never `disabled`) so they hover like + // the other rows; they're quieted visually instead. + expect(devboxItem?.hasAttribute('data-disabled')).toBe(false) + expect(devboxItem?.getAttribute('role')).toBe('option') }) it('shows the run target picker for one ready setup so hosts can be added', () => { @@ -624,10 +625,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const devboxItem = findRunTargetItem('Devbox') - expect( - devboxItem?.getAttribute('aria-disabled') === 'true' || - devboxItem?.hasAttribute('data-disabled') - ).toBe(true) + expect(devboxItem).toBeTruthy() const connectButton = [...(devboxItem?.querySelectorAll('button') ?? [])].find((button) => button.textContent?.includes('Connect') ) @@ -724,10 +722,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const addHost = findRunTargetItem('Add host') expect(addHost).toBeTruthy() - // Hovering the row (no click) opens its submenu so it feels like a menu. React derives - // onPointerEnter from a bubbling pointerover, which is what jsdom dispatches here. + // Hovering the row (no click) opens its submenu so it feels like a menu. + // Hover arms rows via mousemove, matching the project picker. act(() => { - addHost?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })) + addHost?.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) }) expect(findRunTargetItem('Add SSH host')).toBeTruthy() @@ -786,10 +784,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(current.container.textContent).toContain('Run on') expect(current.container.textContent).not.toContain('VM recipe') - const runTargetButton = - current.container.querySelector('button[role="combobox"]') - expect(runTargetButton).toBeTruthy() - act(() => runTargetButton?.click()) + openRunTargetPicker(current.container) expect(document.body.textContent).toContain('Per-Workspace Environment') const ephemeralVmItem = [ @@ -798,7 +793,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(ephemeralVmItem).toBeTruthy() act(() => ephemeralVmItem?.click()) - const recipeItem = [...document.body.querySelectorAll('[cmdk-item]')].find( + const recipeItem = [...document.body.querySelectorAll('[role="option"]')].find( (item) => item.textContent?.includes('Vercel Sandbox') ) expect(recipeItem).toBeTruthy() @@ -840,12 +835,13 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { onEphemeralVmRecipeChange: (recipeId) => recipeChanges.push(recipeId) }) - const runTargetButton = - current.container.querySelector('button[role="combobox"]') - expect(runTargetButton?.textContent).toContain('Per-Workspace Environment') - act(() => runTargetButton?.click()) + const runTargetShell = current.container.querySelector( + 'div[data-run-target-combobox-root="true"]' + ) + expect(runTargetShell?.textContent).toContain('Per-Workspace Environment') + openRunTargetPicker(current.container) - const builderItem = [...document.body.querySelectorAll('[cmdk-item]')].find( + const builderItem = [...document.body.querySelectorAll('[role="option"]')].find( (item) => item.textContent?.includes('Builder') ) expect(builderItem).toBeTruthy() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 0fdcc3459ef..edf657c3118 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1,38 +1,27 @@ /* eslint-disable max-lines -- Why: keep the full composer card markup together so the inline and modal variants share one UI surface. */ import React from 'react' -import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AlertTriangle, Check, ChevronDown, - ChevronRight, - ChevronsUpDown, - Cloud, CornerDownLeft, FolderPlus, LoaderCircle, - Monitor, PlugZap, - Plus, - Settings2, - Server + Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandItem, - CommandList, - CommandSeparator -} from '@/components/ui/command' -import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' +import { + DEFAULT_DISABLED_TUI_AGENTS, + filterEnabledTuiAgents +} from '../../../shared/tui-agent-selection' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' @@ -44,7 +33,6 @@ import { } from '@/lib/text-control-paste' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' -import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import type { GitHubWorkItem, GitLabWorkItem, @@ -60,6 +48,7 @@ import SmartWorkspaceNameField, { } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' +import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' import { AddRemoteHostDialog, type AddRemoteHostMode @@ -68,11 +57,9 @@ import type { SetupConfig } from '@/lib/new-workspace' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import type { NeedsSetupProjectHostOption, - ProjectHostSetupOption, - ReadyProjectHostSetupOption + ProjectHostSetupOption } from '@/lib/project-host-setup-options' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { SshConnectionStatus } from '../../../shared/ssh-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeStatus } from '../../../shared/runtime-types' @@ -246,549 +233,6 @@ async function withUiConnectTimeout(promise: Promise): Promise { } } -function getRecipeCommandDisplay(command: string): string { - const trimmed = command.trim() - const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/) - return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed -} - -function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string { - if (recipe.destroyDisabled) { - return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled') - } - if (recipe.destroy) { - return translate( - 'auto.components.NewWorkspaceComposerCard.destroyConfigured', - 'destroy configured' - ) - } - return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy') -} - -type WorkspaceRunTargetComboboxProps = { - hostOptions: readonly ProjectHostSetupOption[] - hostValue: string | null - onHostChange?: (setupId: string) => void - recipes: EphemeralVmRecipeOption[] - recipeValue: string | null - onRecipeChange?: (recipeId: string | null) => void - onAddRemoteServer?: () => void - onAddSshHost?: () => void - onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise | void -} - -type HostPathTooltipPosition = { - left: number - top: number - maxWidth: number -} - -const HOST_PATH_TOOLTIP_DELAY_MS = 400 -const HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX = 8 -const HOST_PATH_TOOLTIP_TRIGGER_GAP_PX = 4 - -function HostPathTooltip({ path }: { path: string }): React.JSX.Element { - const tooltipId = React.useId() - const timerRef = React.useRef | null>(null) - const pointerInsideRef = React.useRef(false) - const [position, setPosition] = React.useState(null) - - const hideTooltip = React.useCallback((): void => { - pointerInsideRef.current = false - if (timerRef.current !== null) { - clearTimeout(timerRef.current) - timerRef.current = null - } - setPosition(null) - }, []) - - React.useEffect(() => hideTooltip, [hideTooltip]) - - const handlePointerEnter = React.useCallback((event: React.PointerEvent): void => { - pointerInsideRef.current = true - const trigger = event.currentTarget - if (timerRef.current !== null) { - clearTimeout(timerRef.current) - } - timerRef.current = setTimeout(() => { - timerRef.current = null - if (!pointerInsideRef.current || !trigger.isConnected) { - return - } - const rect = trigger.getBoundingClientRect() - // Why: anchor under the hovered path, capping width to the viewport edge so a long path wraps instead of flying off-screen. - const left = Math.max(HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX, rect.left) - setPosition({ - left, - top: rect.bottom + HOST_PATH_TOOLTIP_TRIGGER_GAP_PX, - maxWidth: window.innerWidth - left - HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX - }) - }, HOST_PATH_TOOLTIP_DELAY_MS) - }, []) - - return ( - <> - {/* Trigger is the truncated path line itself, so the tooltip only appears when hovering it. */} -
- {path} -
- {/* Why: a fixed, pointer-transparent portal cannot reflow cmdk or become the hover target. */} - {position - ? createPortal( - , - document.body - ) - : null} - - ) -} - -// Why: the local machine isn't a server — give it a monitor glyph so it reads as "this computer". -function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element { - const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server - return -} - -function WorkspaceRunTargetCombobox({ - hostOptions, - hostValue, - onHostChange, - recipes, - recipeValue, - onRecipeChange, - onAddRemoteServer, - onAddSshHost, - onConnectHost -}: WorkspaceRunTargetComboboxProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) - const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false) - const [hostActionsOpen, setHostActionsOpen] = React.useState(false) - // Why: track in-flight connects per host (a Set, not a single id) so connecting one host — or - // one connect stalling — never disables connecting to the others. - const [connectingHostIds, setConnectingHostIds] = React.useState>( - () => new Set() - ) - const readyHostOptions = React.useMemo( - () => - hostOptions.filter( - (option): option is ReadyProjectHostSetupOption => option.kind === 'ready' - ), - [hostOptions] - ) - const needsSetupHostOptions = React.useMemo( - () => - hostOptions.filter( - (option): option is NeedsSetupProjectHostOption => option.kind === 'needs-setup' - ), - [hostOptions] - ) - const selectedHost = - readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null - const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null - const selectedValue = selectedRecipe - ? `recipe:${selectedRecipe.id}` - : selectedHost - ? `host:${selectedHost.id}` - : '' - const ephemeralVmLabel = translate( - 'auto.components.NewWorkspaceComposerCard.ephemeralVm', - 'Per-Workspace Environment' - ) - - const handleHostSelect = React.useCallback( - (setupId: string): void => { - if (!readyHostOptions.some((candidate) => candidate.id === setupId)) { - return - } - onHostChange?.(setupId) - onRecipeChange?.(null) - setOpen(false) - }, - [onHostChange, onRecipeChange, readyHostOptions] - ) - - const handleRecipeSelect = React.useCallback( - (recipeId: string): void => { - if (!recipes.some((recipe) => recipe.id === recipeId)) { - return - } - onRecipeChange?.(recipeId) - setVmRecipesOpen(false) - setOpen(false) - }, - [onRecipeChange, recipes] - ) - - const connectHost = React.useCallback( - async (option: NeedsSetupProjectHostOption): Promise => { - if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) { - return - } - setConnectingHostIds((current) => new Set(current).add(option.hostId)) - try { - await onConnectHost(option) - } finally { - // Why: always clear the in-flight id when the connect settles (success, failure, or - // timeout) so the row's spinner stops. No mounted-guard — a stale setState on an - // unmounted component is a harmless no-op in React 18, and the guard's ref was the - // source of a StrictMode bug that left the spinner stuck. - setConnectingHostIds((current) => { - if (!current.has(option.hostId)) { - return current - } - const next = new Set(current) - next.delete(option.hostId) - return next - }) - } - }, - [connectingHostIds, onConnectHost] - ) - - // Why: the submenu rows open their popover on hover/focus (not just click) to feel like a - // menu; opening one closes the other so the two right-side popovers never stack. - const openVmRecipesSubmenu = React.useCallback((): void => { - setHostActionsOpen(false) - setVmRecipesOpen(true) - }, []) - - const openHostActionsSubmenu = React.useCallback((): void => { - setVmRecipesOpen(false) - setHostActionsOpen(true) - }, []) - - // Why: hovering/highlighting a plain host row dismisses any submenu popover left open from - // passing over the recipe/add-host rows, so a stray submenu can't linger over the list. - const closeSubmenus = React.useCallback((): void => { - setVmRecipesOpen(false) - setHostActionsOpen(false) - }, []) - - const handleAddSshHost = React.useCallback((): void => { - setHostActionsOpen(false) - setOpen(false) - onAddSshHost?.() - }, [onAddSshHost]) - - const handleAddRemoteServer = React.useCallback((): void => { - setHostActionsOpen(false) - setOpen(false) - onAddRemoteServer?.() - }, [onAddRemoteServer]) - - return ( - - - - - - - - - {translate( - 'auto.components.NewWorkspaceComposerCard.noRunTargets', - 'No run targets are ready for this project.' - )} - - {readyHostOptions.map((option) => ( - handleHostSelect(option.id)} - onPointerEnter={closeSubmenus} - onFocus={closeSubmenus} - className="items-center gap-2 px-3 py-1.5" - > - - -
-
{option.label}
- -
-
- ))} - {/* Why: not-connected hosts are dormant, not errors — a separator (no heading) sets - them off as a secondary group without labeling the recipe/add-host rows below. */} - {needsSetupHostOptions.length > 0 ? ( - <> - - {needsSetupHostOptions.map((option) => ( - // Why: setup-on-host is a follow-up; this row only explains why the host cannot - // run the workspace yet. It stays highlightable (no `disabled`) so it matches the - // hover of the other rows, but selecting it is a no-op since it isn't ready. - {}} - onPointerEnter={closeSubmenus} - onFocus={closeSubmenus} - className="items-center gap-2 px-3 py-1.5" - > -
- - {/* Why: show a spinner while connecting; otherwise reserve the alarm glyph for - a genuine connection error and give a dormant disconnected host the neutral - server icon. */} - {connectingHostIds.has(option.hostId) ? ( - - ) : option.attention ? ( - - ) : ( - - )} -
-
{option.label}
-
- {option.detail} -
-
-
- {option.connectAction && onConnectHost ? ( - - ) : null} -
- ))} - - ) : null} - {/* Why: separate the host list from the per-workspace-env row; the "Add host" action - is pinned below the list with its own border, so it needs no separator here. */} - {recipes.length > 0 && - (readyHostOptions.length > 0 || needsSetupHostOptions.length > 0) ? ( - - ) : null} - {recipes.length > 0 ? ( - - - {/* Why: a real CommandItem (not a raw button) so cmdk registers it — fixes missing rows, uneven height, and double-highlight. */} - - - -
-
{ephemeralVmLabel}
- {/* Why: a second line so this row matches the two-line host options above and hints what it opens. */} -
- {translate( - 'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint', - 'Provision an on-demand environment from a recipe' - )} -
-
- -
-
- - - - {recipes.map((recipe) => ( - handleRecipeSelect(recipe.id)} - className="items-center gap-2 px-3 py-1.5" - > - -
-
{recipe.name}
-
- {getRecipeCommandDisplay(recipe.create)} ·{' '} - {getRecipeDestroyLabel(recipe)} -
- {recipe.description ? ( -
- {recipe.description} -
- ) : null} -
-
- ))} -
-
-
-
- ) : null} -
- {/* Why: pin "Add host" below the scrollable list — mirrors the Project combobox's - "Add a new project" footer so it keeps a compact single-row height and one clean - divider instead of a taller in-list row above the popover edge. */} -
- - {/* Why: an Anchor (not a Trigger) so click/hover/focus all just open the submenu — - a Trigger's own toggle would fight the hover-open and close it on the same click. */} - - - - - {/* Why: pin an empty value so cmdk doesn't auto-highlight the first row on open — - matches the recipes submenu, which leaves nothing highlighted by default. */} - - - - -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addSshHost', - 'Add SSH host' - )} -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addSshHostHint', - 'Use an existing machine over SSH' - )} -
-
-
- - -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer', - 'Add Remote Orca Server' - )} -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint', - 'Pair another Orca runtime' - )} -
-
-
-
-
-
-
-
-
-
-
- ) -} - function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element { // Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and // the run-setup toggle live in the section header above, so the card carries no chrome of its @@ -955,7 +399,9 @@ export default function NewWorkspaceComposerCard({ const openModal = useAppStore((s) => s.openModal) const activeModal = useAppStore((s) => s.activeModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) - const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) + const disabledTuiAgents = useAppStore( + (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS + ) const updateSettings = useAppStore((s) => s.updateSettings) const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() @@ -1253,11 +699,14 @@ export default function NewWorkspaceComposerCard({

) : null} {shouldShowRunTargetPicker ? ( -
+ // Why: Run on is nested in the Project block (so they share the + // error/empty states), which put it on the block's 4px rhythm. It's + // its own field, so give it the 16px other fields get. +
- { updater: { check, dismissNudge: vi.fn(), + dismissAvailableUpdate: vi.fn().mockResolvedValue(undefined), download, quitAndInstall: vi.fn().mockResolvedValue(undefined) } diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index 9d4b75094d2..6eed56fad3e 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -41,7 +41,8 @@ beforeEach(() => { updater: { download: vi.fn().mockResolvedValue(undefined), quitAndInstall: vi.fn().mockResolvedValue(undefined), - dismissNudge: vi.fn().mockResolvedValue(undefined) + dismissNudge: vi.fn().mockResolvedValue(undefined), + dismissAvailableUpdate: vi.fn().mockResolvedValue(undefined) } }, matchMedia: vi.fn().mockReturnValue({ diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx index 4865021f0a4..ac551fbc111 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React, { type ReactNode } from 'react' -import { act } from 'react' +import React, { type ReactNode, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts index f5bc9c4320c..9d3837b19a3 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts @@ -1,6 +1,6 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { AutomationDraft } from './AutomationEditorDialog' import { AutomationCustomCronPanel, @@ -9,9 +9,11 @@ import { } from './AutomationCustomCronPanel' import { AUTOMATION_SCHEDULE_PRESET_OPTIONS, + getAutomationSchedulePresetLabel, getSchedulePresetDraft } from './AutomationSchedulePicker' import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { i18n } from '@/i18n/i18n' const BASE_DRAFT: AutomationDraft = { name: '', @@ -33,8 +35,34 @@ const BASE_DRAFT: AutomationDraft = { } describe('AutomationSchedulePicker', () => { - it('offers custom cron as a selectable cadence', () => { - expect(AUTOMATION_SCHEDULE_PRESET_OPTIONS).toContainEqual(['custom', 'Custom cron']) + // Reset here, not after the assertion: a failed expect would otherwise leak the locale. + afterEach(async () => { + await i18n.changeLanguage('en') + }) + + it('provides an i18n key for every selectable cadence (#10043)', () => { + expect(AUTOMATION_SCHEDULE_PRESET_OPTIONS).toContainEqual([ + 'custom', + 'Custom cron', + 'auto.components.automations.AutomationSchedulePicker.ddba78647e' + ]) + for (const [value, fallbackLabel, labelKey] of AUTOMATION_SCHEDULE_PRESET_OPTIONS) { + expect(value).not.toBe('') + expect(fallbackLabel).not.toBe('') + expect(labelKey).toMatch( + /^auto\.components\.automations\.AutomationSchedulePicker\.[0-9a-f]{10}$/ + ) + } + }) + + it.each([ + ['zh', ['每小时', '每天', '工作日', '每周', '自定义 cron']], + ['ja', ['毎時', '毎日', '平日', '毎週', 'カスタム cron']], + ['ko', ['매시간', '매일', '평일', '매주', '사용자 지정 cron']], + ['es', ['Cada hora', 'Diario', 'Días laborables', 'Semanal', 'Cron personalizado']] + ])('translates every cadence option in %s', async (locale, labels) => { + await i18n.changeLanguage(locale) + expect(AUTOMATION_SCHEDULE_PRESET_OPTIONS.map(getAutomationSchedulePresetLabel)).toEqual(labels) }) it('seeds custom cron from the current simple schedule', () => { diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx index eb203f9c36c..1f6f6d96ebf 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx @@ -25,12 +25,20 @@ import { translate } from '@/i18n/i18n' const FIELD_CONTROL_CLASS = 'border-input bg-input/30 shadow-xs dark:bg-input/30' export const AUTOMATION_SCHEDULE_PRESET_OPTIONS = [ - ['hourly', 'Hourly'], - ['daily', 'Daily'], - ['weekdays', 'Weekdays'], - ['weekly', 'Weekly'], - ['custom', 'Custom cron'] -] as const satisfies readonly [AutomationSchedulePreset, string][] + ['hourly', 'Hourly', 'auto.components.automations.AutomationSchedulePicker.55b2ef82a4'], + ['daily', 'Daily', 'auto.components.automations.AutomationSchedulePicker.f0202f3a89'], + ['weekdays', 'Weekdays', 'auto.components.automations.AutomationSchedulePicker.57e83307d0'], + ['weekly', 'Weekly', 'auto.components.automations.AutomationSchedulePicker.837d902bba'], + ['custom', 'Custom cron', 'auto.components.automations.AutomationSchedulePicker.ddba78647e'] +] as const satisfies readonly (readonly [AutomationSchedulePreset, string, string])[] + +export function getAutomationSchedulePresetLabel([, fallbackLabel, labelKey]: readonly [ + AutomationSchedulePreset, + string, + string +]): string { + return translate(labelKey, fallbackLabel) +} const DAY_OPTIONS = [ ['0', 'Sunday'], @@ -191,9 +199,9 @@ export function AutomationSchedulePicker({ - {AUTOMATION_SCHEDULE_PRESET_OPTIONS.map(([value, presetLabel]) => ( + {AUTOMATION_SCHEDULE_PRESET_OPTIONS.map(([value, fallbackLabel, labelKey]) => ( - {presetLabel} + {getAutomationSchedulePresetLabel([value, fallbackLabel, labelKey])} ))} diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx index 973f2089740..b4e6969bf52 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx @@ -1,12 +1,14 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: dropdown visibility depends on DOM focus plus browser-history suggestions, so the close path is an imperative popover sync. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Globe, Search } from 'lucide-react' +import { Globe } from 'lucide-react' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url' import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions' +import { shouldOverlayBrowserAddressBar } from './browser-address-bar-expansion' +import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList' type BrowserAddressBarProps = { value: string @@ -38,6 +40,24 @@ export default function BrowserAddressBar({ const openedAtRef = useRef(0) const blurCloseTimerRef = useRef(null) const closingResetTimerRef = useRef(null) + const slotRef = useRef(null) + const [inlineWidth, setInlineWidth] = useState(null) + + // Why: the slot keeps its flex width even while the bar overlays the toolbar, + // so measuring it here (not the form) cannot oscillate with the overlay. + useEffect(() => { + const slot = slotRef.current + if (!slot || typeof ResizeObserver === 'undefined') { + return + } + const syncWidth = (): void => setInlineWidth(slot.getBoundingClientRect().width) + syncWidth() + const observer = new ResizeObserver(syncWidth) + observer.observe(slot) + return () => observer.disconnect() + }, []) + + const overlay = shouldOverlayBrowserAddressBar({ inlineWidth, focused: open }) const clearAddressBarTimers = useCallback((): void => { if (blurCloseTimerRef.current !== null) { @@ -338,111 +358,102 @@ export default function BrowserAddressBar({ }, [dismissSuggestions, dismissSuggestionsRef]) return ( - { - // Why: Radix fires onOpenChange(false) when it detects an outside - // interaction, but during the focus-retry loop the input may still - // hold focus. Only allow programmatic closes (setOpen(false) from - // our handlers) or genuine outside dismissals. - if (!next && inputRef.current && document.activeElement === inputRef.current) { - return - } - if (!next) { - restoreTypedQuery() - } - setOpen(next) - }} - > - -
{ - event.preventDefault() - setOpen(false) - clearSuggestionPreview() - setAutocompleteQuery(value) - onSubmit() - }} - > - - { - const nextValue = event.target.value - // Why: typing creates a new suggestion list, so keyboard selection - // should return to the derived top match instead of a stale row. - // Clearing preview state here also prevents stale hover/selection - // from repopulating the input after Cmd+A → Delete. - prePreviewValueRef.current = null - setSelectedValueOverride(null) - setAutocompleteQuery(nextValue) - onChange(nextValue) + // Why: min-w-11 keeps the leading globe a real hit target once the toolbar + // squeezes the bar away — without it neighbouring buttons overlap the only + // affordance for reopening the URL field. +
+ { + // Why: Radix fires onOpenChange(false) when it detects an outside + // interaction, but during the focus-retry loop the input may still + // hold focus. Only allow programmatic closes (setOpen(false) from + // our handlers) or genuine outside dismissals. + if (!next && inputRef.current && document.activeElement === inputRef.current) { + return + } + if (!next) { + restoreTypedQuery() + } + setOpen(next) + }} + > + + inputRef.current?.focus()} + onSubmit={(event) => { + event.preventDefault() + setOpen(false) + clearSuggestionPreview() + setAutocompleteQuery(value) + onSubmit() }} - role="combobox" - aria-expanded={open} - aria-controls="browser-history-listbox" - aria-autocomplete="list" - /> - - - {suggestions.length > 0 && ( - { - // Why: prevent the popover from stealing focus away from the - // address bar input. The user is still typing; the popover is - // an overlay of suggestions, not a focus target. - e.preventDefault() - }} - > - - - - {suggestions.map((entry) => ( - handleSelect(entry.url)} - className="flex items-center gap-2 px-3 py-2" - > - {entry.isSearch ? ( - - ) : ( - - )} -
- {entry.title} - {entry.subtitle ? ( - - {entry.subtitle} - - ) : null} -
-
- ))} -
-
-
-
- )} -
+ + { + const nextValue = event.target.value + // Why: typing creates a new suggestion list, so keyboard selection + // should return to the derived top match instead of a stale row. + // Clearing preview state here also prevents stale hover/selection + // from repopulating the input after Cmd+A → Delete. + prePreviewValueRef.current = null + setSelectedValueOverride(null) + setAutocompleteQuery(nextValue) + onChange(nextValue) + }} + role="combobox" + aria-expanded={open} + aria-controls="browser-history-listbox" + aria-autocomplete="list" + /> + + + {suggestions.length > 0 && ( + { + // Why: prevent the popover from stealing focus away from the + // address bar input. The user is still typing; the popover is + // an overlay of suggestions, not a focus target. + e.preventDefault() + }} + > + + + )} + +
) } diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx new file mode 100644 index 00000000000..4cc13077eee --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx @@ -0,0 +1,46 @@ +import { Globe, Search } from 'lucide-react' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions' + +type BrowserAddressBarSuggestionListProps = { + suggestions: BrowserAddressBarSuggestion[] + selectedValue: string + onSelectedValueChange: (value: string) => void + onSelect: (url: string) => void +} + +export default function BrowserAddressBarSuggestionList({ + suggestions, + selectedValue, + onSelectedValueChange, + onSelect +}: BrowserAddressBarSuggestionListProps): React.ReactElement { + return ( + + + + {suggestions.map((entry) => ( + onSelect(entry.url)} + className="flex items-center gap-2 px-3 py-2" + > + {entry.isSearch ? ( + + ) : ( + + )} +
+ {entry.title} + {entry.subtitle ? ( + {entry.subtitle} + ) : null} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts new file mode 100644 index 00000000000..8d339bb75ae --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH, + isBrowserAddressBarCollapsed, + shouldOverlayBrowserAddressBar +} from './browser-address-bar-expansion' + +describe('isBrowserAddressBarCollapsed', () => { + it('treats an unmeasured slot as roomy so the bar never overlays before layout', () => { + expect(isBrowserAddressBarCollapsed(null)).toBe(false) + }) + + it('collapses below the minimum inline width', () => { + expect(isBrowserAddressBarCollapsed(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH - 1)).toBe(true) + expect(isBrowserAddressBarCollapsed(48)).toBe(true) + }) + + it('stays inline at or above the minimum width', () => { + expect(isBrowserAddressBarCollapsed(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH)).toBe(false) + expect(isBrowserAddressBarCollapsed(640)).toBe(false) + }) +}) + +describe('shouldOverlayBrowserAddressBar', () => { + it('overlays only while a collapsed bar is focused', () => { + expect(shouldOverlayBrowserAddressBar({ inlineWidth: 48, focused: true })).toBe(true) + expect(shouldOverlayBrowserAddressBar({ inlineWidth: 48, focused: false })).toBe(false) + }) + + it('never overlays a bar that already fits', () => { + expect(shouldOverlayBrowserAddressBar({ inlineWidth: 640, focused: true })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts new file mode 100644 index 00000000000..c1959e6a950 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts @@ -0,0 +1,19 @@ +// Every other browser toolbar control is shrink-0, so the address bar absorbs +// all of the squeeze when the pane narrows and ends up as the leading globe +// icon with a zero-width input. Below this slot width the bar overlays the +// toolbar while focused instead, keeping the URL editable (issue #11090). +export const BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH = 220 + +export function isBrowserAddressBarCollapsed(inlineWidth: number | null): boolean { + return inlineWidth !== null && inlineWidth < BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH +} + +export function shouldOverlayBrowserAddressBar({ + inlineWidth, + focused +}: { + inlineWidth: number | null + focused: boolean +}): boolean { + return focused && isBrowserAddressBarCollapsed(inlineWidth) +} diff --git a/src/renderer/src/components/dictation/DictationIndicator.test.tsx b/src/renderer/src/components/dictation/DictationIndicator.test.tsx new file mode 100644 index 00000000000..89fb0e726af --- /dev/null +++ b/src/renderer/src/components/dictation/DictationIndicator.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DictationState } from '../../../../shared/speech-types' +import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' + +const storeState = { + dictationState: 'listening' as DictationState, + partialTranscript: '', + settings: { voice: { dictationMode: 'toggle' } } as { + voice?: { dictationMode?: 'toggle' | 'hold' } + } | null +} + +vi.mock('@/store', () => { + const useAppStore = (selector: (value: typeof storeState) => unknown) => selector(storeState) + return { useAppStore } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +// Why: the real TooltipContent portals out of the trigger's subtree. The mock must +// too, or "the pill itself does not render the chip" would pass vacuously. +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => + createPortal(
{children}
, document.body) +})) + +import { DictationIndicator } from './DictationIndicator' + +function setUserAgent(userAgent: string): void { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }) +} + +const originalUserAgent = navigator.userAgent + +beforeEach(() => { + storeState.dictationState = 'listening' + storeState.partialTranscript = '' + storeState.settings = { voice: { dictationMode: 'toggle' } } + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') +}) + +afterEach(() => { + cleanup() + setUserAgent(originalUserAgent) +}) + +describe('DictationIndicator', () => { + it('stops dictation when the stop button is clicked', () => { + const actions: DictationControlAction[] = [] + const listener = (event: Event): void => { + actions.push((event as CustomEvent).detail) + } + document.addEventListener(DICTATION_CONTROL_EVENT, listener) + + render() + fireEvent.click(screen.getByRole('button', { name: 'Stop dictation' })) + + document.removeEventListener(DICTATION_CONTROL_EVENT, listener) + expect(actions).toEqual(['stop']) + }) + + it('keeps focus on the dictation target when the stop button is pressed', () => { + render() + const stopButton = screen.getByRole('button', { name: 'Stop dictation' }) + + expect(fireEvent.mouseDown(stopButton)).toBe(false) + }) + + it('shows the assigned shortcut in the stop button tooltip in toggle mode', () => { + render() + + const tooltip = screen.getByTestId('tooltip-content') + expect(tooltip.textContent).toContain('Stop dictation') + expect(tooltip.textContent).toContain('⌘') + expect(tooltip.textContent).toContain('E') + }) + + it('keeps the shortcut out of the always-visible pill, showing it only on hover', () => { + const { container } = render() + const pill = container.firstChild as HTMLElement + + expect(pill.textContent).toContain('Listening...') + expect(pill.textContent).not.toContain('⌘') + }) + + it('omits the shortcut chip in hold mode, where release stops dictation', () => { + storeState.settings = { voice: { dictationMode: 'hold' } } + render() + + expect(screen.getByRole('button', { name: 'Stop dictation' })).toBeTruthy() + expect(screen.getByTestId('tooltip-content').textContent).not.toContain('⌘') + }) + + it('hides the stop control once the session is already stopping', () => { + storeState.dictationState = 'stopping' + render() + + expect(screen.getByText('Processing...')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Stop dictation' })).toBeNull() + }) + + it('renders nothing while idle', () => { + storeState.dictationState = 'idle' + const { container } = render() + + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx index f1d026eaf9d..2b8cce868da 100644 --- a/src/renderer/src/components/dictation/DictationIndicator.tsx +++ b/src/renderer/src/components/dictation/DictationIndicator.tsx @@ -1,9 +1,17 @@ import { useAppStore } from '@/store' -import { Mic } from 'lucide-react' +import { Mic, Square } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' +import { dispatchDictationControl } from './dictation-control-events' export function DictationIndicator() { const dictationState = useAppStore((s) => s.dictationState) const partialTranscript = useAppStore((s) => s.partialTranscript) + const isHoldMode = useAppStore((s) => s.settings?.voice?.dictationMode === 'hold') + const shortcut = useShortcutKeyDetails('voice.dictation') if ( dictationState !== 'listening' && @@ -20,10 +28,53 @@ export function DictationIndicator() { ? 'Processing...' : partialTranscript || 'Listening...' + // Why: the stop path is a no-op once the session is already tearing down. + const canStop = dictationState !== 'stopping' + // Hold mode stops on key release, so a "press ⌘E" chip would misstate the binding. + const showShortcut = !isHoldMode && shortcut.keys.length > 0 + const stopLabel = translate( + 'auto.components.dictation.DictationIndicator.335e1bc6cb', + 'Stop dictation' + ) + return ( -
- - {label} +
+ + {label} + {canStop ? ( + <> + + + + + + + {stopLabel} + {showShortcut ? ( + + ) : null} + + + + ) : null}
) } diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx index ee15205457b..ef83b076201 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { createRef, type MutableRefObject } from 'react' -import { act } from 'react' +import { createRef, type MutableRefObject, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DictationState } from '../../../../shared/speech-types' diff --git a/src/renderer/src/components/editor/ChangesModeView.test.tsx b/src/renderer/src/components/editor/ChangesModeView.test.tsx index d8b41e4b31d..ae8d13f5a34 100644 --- a/src/renderer/src/components/editor/ChangesModeView.test.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment happy-dom -import { Suspense } from 'react' -import { act } from 'react' +import { act, Suspense } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '@/store/slices/editor' diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts index 078ed0f5e35..97721b1b6b7 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts @@ -77,6 +77,7 @@ describe('CombinedDiffFileTree navigation mapping', () => { it('expands a collapsed target section and scrolls to its index', () => { const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' } const toggleSection = vi.fn() + const loadSection = vi.fn() const scrollToIndex = vi.fn() const index = handleCombinedDiffFileTreeNavigation({ mode: 'branch', @@ -87,11 +88,13 @@ describe('CombinedDiffFileTree navigation mapping', () => { { key: 'combined-branch:src/view.ts' } ]), toggleSection, + loadSection, scrollToIndex }) expect(index).toBe(1) expect(toggleSection).toHaveBeenCalledWith(1) + expect(loadSection).toHaveBeenCalledWith(1) expect(scrollToIndex).toHaveBeenCalledWith(1) }) diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index ad461641e8c..fc59c2dd75c 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -20,6 +20,7 @@ import { type CombinedDiffFileTreeMode } from './combined-diff-file-tree-model' import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' +import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' export { @@ -100,6 +101,8 @@ export function CombinedDiffFileTree({ const [query, setQuery] = React.useState('') const [excludedExtensions, setExcludedExtensions] = React.useState>(() => new Set()) const [includeViewed, setIncludeViewed] = React.useState(true) + const { handleResizeKeyDown, handleResizeStart, maxWidth, minWidth, treeRef, width } = + useCombinedDiffFileTreeResize(collapsed) const toggleDirectory = React.useCallback((key: string) => { setCollapsedDirectoryKeys((prev) => { const next = new Set(prev) @@ -169,7 +172,11 @@ export function CombinedDiffFileTree({ return ( // Why: this column must be height-bounded so the file list, not the page, // owns overflow when review diffs have more files than fit on screen. -
+ ), + PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
})) +vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] })) + let container: HTMLDivElement let root: Root @@ -66,6 +60,41 @@ const projects: NewWorkspaceProjectOption[] = [ } ] +function field(): HTMLInputElement { + const node = container.querySelector( + 'input[data-project-combobox-root="true"][role="combobox"]' + ) + if (!node) { + throw new Error('project combobox field not found') + } + return node +} + +function openList(): void { + act(() => { + field().dispatchEvent(new FocusEvent('focus', { bubbles: true })) + }) +} + +function rowFor(optionId: string): HTMLElement { + const row = Array.from(container.querySelectorAll('[role="option"]')).find((node) => + node.textContent?.includes(optionId) + ) + if (!row) { + throw new Error(`row not found for ${optionId}`) + } + return row +} + +function type(value: string): void { + const input = field() + act(() => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) @@ -87,9 +116,24 @@ describe('ProjectCombobox', () => { ) }) - const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') - expect(trigger?.textContent).toContain('orca') - expect(trigger?.textContent).not.toContain('SSH') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('orca') + expect(shell?.textContent).not.toContain('SSH') + }) + + it('keeps a focusable combobox that composer focus helpers can target', () => { + act(() => { + root.render() + }) + + const trigger = container.querySelector( + '[data-project-combobox-root="true"][role="combobox"]' + ) + expect(trigger).toBeTruthy() + act(() => { + trigger?.focus() + }) + expect(document.activeElement).toBe(trigger) }) it('selects projects by logical project id', () => { @@ -104,10 +148,9 @@ describe('ProjectCombobox', () => { /> ) }) + openList() act(() => { - container - .querySelector('[data-command-value="github:stablyai/noqa"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + rowFor('stablyai/noqa').dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') @@ -126,14 +169,13 @@ describe('ProjectCombobox', () => { ) }) - const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') - expect(trigger?.textContent).toContain('Platform') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('Platform') + openList() expect(container.textContent).toContain('/tmp/platform') act(() => { - container - .querySelector('[data-command-value="project-group:folder-group"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + rowFor('/tmp/platform').dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group') @@ -152,23 +194,53 @@ describe('ProjectCombobox', () => { /> ) }) + openList() - const addButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent?.includes('Add a new project') + const addRow = Array.from(container.querySelectorAll('[role="option"]')).find( + (node) => node.textContent?.includes('Add a new project') ) - expect(addButton).toBeTruthy() + expect(addRow).toBeTruthy() act(() => { - addButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onAddProject).toHaveBeenCalledTimes(1) }) + it('keeps "Add a new project" reachable when the search matches nothing', () => { + const onAddProject = vi.fn() + + act(() => { + root.render( + + ) + }) + openList() + type('zzzznomatch') + + expect(container.textContent).toContain('No projects match your search.') + const addRow = Array.from(container.querySelectorAll('[role="option"]')).find( + (node) => node.textContent?.includes('Add a new project') + ) + expect(addRow).toBeTruthy() + + act(() => { + addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onAddProject).toHaveBeenCalledTimes(1) + }) + it('omits the "Add a new project" action when no handler is provided', () => { act(() => { root.render() }) + openList() expect(container.textContent).not.toContain('Add a new project') }) @@ -198,8 +270,163 @@ describe('ProjectCombobox', () => { ) }) + openList() expect(container.textContent).toContain('/workspace/storefront/merchant') expect(container.textContent).toContain('/workspace/admin/merchant') }) + + it('filters as the user types, without a second search box', () => { + act(() => { + root.render() + }) + openList() + // The field itself is the only text input in the control. + expect(container.querySelectorAll('input')).toHaveLength(1) + + type('noq') + expect(container.textContent).toContain('stablyai/noqa') + expect(container.textContent).not.toContain('stablyai/orca') + }) + + it('commits the armed row on Enter', () => { + const onValueChange = vi.fn() + const onValueSelected = vi.fn() + + act(() => { + root.render( + + ) + }) + openList() + type('noq') + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') + expect(onValueSelected).toHaveBeenCalledWith('github:stablyai/noqa') + }) + + it('arms "Add a new project" when a query matches nothing, so Enter is never a wrong guess', () => { + const onAddProject = vi.fn() + const onValueChange = vi.fn() + + act(() => { + root.render( + + ) + }) + openList() + type('zzzznomatch') + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onAddProject).toHaveBeenCalledTimes(1) + expect(onValueChange).not.toHaveBeenCalled() + }) + + it('restores the committed project on Escape instead of stranding a stale query', () => { + act(() => { + root.render( + + ) + }) + openList() + type('zzzznomatch') + expect(field().value).toBe('zzzznomatch') + // Radix closes the popover itself on Escape/outside-click, leaving a closed + // list with a live query — the state that used to strand the field showing + // text matching nothing while hiding the committed project. + act(() => { + container + .querySelector('[data-test-close-popover]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + + expect(field().value).toBe('') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('orca') + }) + + it('drops an uncommitted query when the list closes, so junk text never persists', () => { + act(() => { + root.render( + + ) + }) + openList() + type('asasdasd') + expect(field().value).toBe('asasdasd') + + // Blur / outside-click closes via Radix, not the key handler. + act(() => { + container + .querySelector('[data-test-close-popover]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(field().value).toBe('') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('orca') + }) + + it('marks the field invalid so a failed create press can turn it red', () => { + act(() => { + root.render( + + ) + }) + + expect(field().getAttribute('aria-invalid')).toBe('true') + expect(field().getAttribute('aria-describedby')).toBe('project-error') + }) + + it('owns every option from the listbox, with no unroled wrapper in between', () => { + act(() => { + root.render( + + ) + }) + openList() + + const listbox = container.querySelector('[role="listbox"]') + expect(listbox).toBeTruthy() + const options = Array.from(listbox?.querySelectorAll('[role="option"]') ?? []) + expect(options.length).toBeGreaterThan(0) + for (const option of options) { + let parent = option.parentElement + while (parent && parent !== listbox) { + // A bare wrapper here breaks the listbox → option relationship for AT. + expect(parent.getAttribute('role')).toBeTruthy() + parent = parent.parentElement + } + } + }) }) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index f1dcea89f2a..9f29eb4c407 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -1,21 +1,18 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown, FolderOpen, FolderPlus } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import React, { useCallback, useMemo } from 'react' +import { ChevronDown, FolderPlus } from 'lucide-react' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/utils' -import { - searchNewWorkspaceProjectOptions, - type NewWorkspaceProjectOption -} from '@/lib/new-workspace-project-options' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import { translate } from '@/i18n/i18n' +import { + getAmbiguousProjectOptionIds, + rankProjectOptions, + sectionProjectOptions +} from './project-combobox-matching' +import { ProjectOptionDetail, ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow' +import { useRecentProjectIds } from './use-recent-project-ids' +import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox' +import { COMBOBOX_FIELD_SHELL, COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' type ProjectComboboxProps = { options: readonly NewWorkspaceProjectOption[] @@ -29,6 +26,16 @@ type ProjectComboboxProps = { describedBy?: string } +const ADD_PROJECT_KEY = 'add-project' +const ROOT_ATTRIBUTE = 'data-project-combobox-root' + +/** + * Type-ahead project picker: the field *is* the search, so there's no trigger + * wrapping a second search box. Exactly one row is armed at any time and Enter + * takes it; hovering arms, so the pointer and the keyboard drive one cursor. + * "Add a new project" is pinned to the popover edge so it stays reachable + * without scrolling, in every state including no-matches and no-projects. + */ export default function ProjectCombobox({ options, value, @@ -40,217 +47,303 @@ export default function ProjectCombobox({ invalid = false, describedBy }: ProjectComboboxProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [commandValue, setCommandValue] = useState('') - const inputRef = React.useRef(null) - const focusFrameRef = React.useRef(null) - const selectedProject = useMemo( - () => options.find((option) => option.id === value) ?? null, - [options, value] + const recentIds = useRecentProjectIds() + // Ranking depends on the query the hook owns, so rows are derived from it and + // handed back; `matches`/`sections` are recomputed from the same query below. + const deriveRowKeys = useCallback( + (query: string): string[] => [ + ...rankProjectOptions(options, query, recentIds).map((match) => match.option.id), + ...(onAddProject ? [ADD_PROJECT_KEY] : []) + ], + [onAddProject, options, recentIds] ) - const filteredOptions = useMemo( - () => searchNewWorkspaceProjectOptions(options, query), - [options, query] + const { + query, + setQuery, + open, + setOpen, + close, + handleOpenChange, + armedKey, + arm, + moveArm, + inputRef, + listId, + setListNode + } = useTypeAheadCombobox(deriveRowKeys) + + const matches = useMemo( + () => rankProjectOptions(options, query, recentIds), + [options, query, recentIds] ) - - const cancelFocusFrame = useCallback((): void => { - if (focusFrameRef.current !== null) { - cancelAnimationFrame(focusFrameRef.current) - focusFrameRef.current = null - } - }, []) - - const setInputNode = useCallback( - (node: HTMLInputElement | null): void => { - if (node === null) { - cancelFocusFrame() - } - inputRef.current = node - }, - [cancelFocusFrame] + const sections = useMemo( + () => sectionProjectOptions(matches, query, recentIds), + [matches, query, recentIds] ) + const ambiguous = useMemo(() => getAmbiguousProjectOptionIds(options), [options]) + const selected = options.find((option) => option.id === value) ?? null + // A committed pick shows as the field's own content; typing replaces it. + const committed = selected !== null && query.length === 0 - const focusSearchInput = useCallback((): void => { - cancelFocusFrame() - focusFrameRef.current = requestAnimationFrame(() => { - focusFrameRef.current = null - inputRef.current?.focus() - }) - }, [cancelFocusFrame]) - - const handleOpenChange = useCallback( - (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - setCommandValue(value ?? '') + const commit = useCallback( + (key: string | null): void => { + if (key === null) { return } - cancelFocusFrame() - setQuery('') - }, - [cancelFocusFrame, value] - ) - - const handleSelect = useCallback( - (projectId: string): void => { - onValueChange(projectId) - setOpen(false) - setQuery('') - onValueSelected?.(projectId) - }, - [onValueChange, onValueSelected] - ) - - const handleAddProject = useCallback((): void => { - setOpen(false) - setQuery('') - onAddProject?.() - }, [onAddProject]) - - const handleTriggerKeyDown = useCallback( - (event: React.KeyboardEvent): void => { - if (open) { + close() + if (key === ADD_PROJECT_KEY) { + onAddProject?.() return } + onValueChange(key) + onValueSelected?.(key) + }, + [close, onAddProject, onValueChange, onValueSelected] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() - setCommandValue(value ?? '') setOpen(true) + moveArm(event.key === 'ArrowDown' ? 1 : -1) return } - if (event.metaKey || event.ctrlKey || event.altKey) { - return - } - if (event.key.length === 1 && /\S/.test(event.key)) { + if (event.key === 'Enter' && open) { event.preventDefault() - setCommandValue(value ?? '') - setQuery(event.key) + commit(armedKey) + return + } + // Why: not gated on `open` — a leftover query with the list closed would + // otherwise strand the field showing text that matches nothing and hides + // the committed project. Escape always restores the committed display, + // and only bubbles (to close the dialog) when there's nothing to undo. + if (event.key === 'Escape' && (open || query.length > 0)) { + event.preventDefault() + event.stopPropagation() + close() + return + } + // Backspace on a committed pick unsticks it back into editable text. + if (event.key === 'Backspace' && committed && selected) { + event.preventDefault() + setQuery(selected.displayName) setOpen(true) } }, - [open, value] + [armedKey, close, commit, committed, moveArm, open, query, selected, setOpen, setQuery] ) return ( - - - + + {committed && selected ? : null} + +
+ { + setQuery(event.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className={cn( + 'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground', + committed && 'text-transparent caret-foreground' + )} + /> + {/* Painted over the input so a long name and its path can shrink at + different rates instead of truncating as one flat string. */} + {committed && selected ? ( + + ) : null} +
+ +
+ { - event.preventDefault() - focusSearchInput() + sideOffset={4} + // Why opaque + no fade: this popover lands directly on the composer + // dialog, not the app canvas. The shared surface is translucent and + // fades 0→1, so mid-animation the Name field underneath reads straight + // through the list — two layers at once, which is the "double flash". + // An opaque surface that zooms without fading resolves it. (`bg-popover` + // alone loses to the primitive's arbitrary-value background, hence the + // matching arbitrary form.) Every other caller keeps the blur and fade. + className={cn( + 'flex w-[var(--radix-popover-trigger-width)] min-w-[17rem] flex-col p-0', + COMBOBOX_POPOVER_SURFACE + )} + // Focus stays in the field — it's the search box — so the popover must + // not steal it on open, nor yank it back on close after a pick. + onOpenAutoFocus={(event) => event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // Why: the field lives in the anchor, not inside the content, so Radix + // sees a focus/pointer event "outside" the layer and dismisses it the + // instant you tab in. Keep the layer open whenever the interaction is + // within this control; genuine outside events still close it. + onFocusOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + onInteractOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } }} > - - - - - {translate( - 'auto.components.new.workspace.ProjectCombobox.empty', - 'No projects match your search.' - )} - - {filteredOptions.map((option) => ( - handleSelect(option.id)} - className="items-center gap-2 px-3 py-1.5" - > - -
- {option.kind === 'project-group' ? ( -
- - {option.displayName} -
- ) : ( - - )} -

- {option.detail} -

-
-
+ {/* The listbox wraps a scrolling pane plus the pinned Add row, so both + stay `option` children of one listbox. */} +
+ {/* Why: `presentation` — this element exists to scroll, and an + unroled div between a listbox and its options breaks the + ownership relationship assistive tech relies on. */} +
+ {matches.length === 0 ? ( + // Why: row-height rather than a tall centred block — a 60px panel + // next to 32px rows reads as a different kind of surface and + // makes an empty result feel like an error. +

+ {options.length === 0 + ? translate( + 'auto.components.new.workspace.ProjectCombobox.noProjects', + 'No projects yet.' + ) + : translate( + 'auto.components.new.workspace.ProjectCombobox.empty', + 'No projects match your search.' + )} +

+ ) : null} + {sections.map((section) => ( + // Why: `role="group"` — a bare div between a listbox and its + // options breaks the ownership relationship for screen readers, + // which is the only thing that makes the headings announceable. +
+ {section.heading ? ( + + ) : null} + {section.items.map((scored) => ( + arm(scored.option.id)} + onCommit={() => commit(scored.option.id)} + /> + ))} +
))} - +
{onAddProject ? ( - // Why: pinned below the scrollable list so "Add a new project" stays - // reachable in every state, including when the list is empty and - // CommandEmpty shows "No projects match your search." -
- +
event.preventDefault()} + onMouseMove={() => arm(ADD_PROJECT_KEY)} + onClick={() => commit(ADD_PROJECT_KEY)} + className={cn( + 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', + armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground' + )} + > + + + {translate( + 'auto.components.new.workspace.ProjectCombobox.addProject', + 'Add a new project' + )} +
) : null} - +
) diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx new file mode 100644 index 00000000000..e031bed5cf2 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { FolderOpen } from 'lucide-react' +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import { splitDetailForElision } from './project-combobox-matching' + +/** Identity mark shared by the field and every row, so a project reads the same in both. */ +export function ProjectOptionMark({ + option +}: { + option: NewWorkspaceProjectOption +}): React.JSX.Element { + return option.kind === 'project-group' ? ( + + ) : ( + // Square, matching the mark everywhere else (jump palette, sidebar). + + ) +} + +/** Underlines the matched characters so a fuzzy hit is legible, not mysterious. */ +export function MatchedText({ + text, + hits, + className +}: { + text: string + hits: readonly number[] + className?: string +}): React.JSX.Element { + const marks = new Set(hits) + if (marks.size === 0) { + return {text} + } + return ( + + {[...text].map((char, index) => + marks.has(index) ? ( + + {char} + + ) : ( + char + ) + )} + + ) +} + +/** + * Detail line that keeps its final two path segments when space runs out — + * `~/dev/…/services/checkout-api` stays distinct from its `-web` sibling, where + * a plain truncate would render both identically. + */ +export function ProjectOptionDetail({ + detail, + hits, + className +}: { + detail: string + hits?: readonly number[] + className?: string +}): React.JSX.Element { + const split = splitDetailForElision(detail) + if (!split) { + return ( + + {hits ? : detail} + + ) + } + return ( + + {/* Head collapses first; the tail only truncates once the head is gone. */} + {split.head} + /{split.tail} + + ) +} + +export function ProjectOptionRow({ + option, + nameHits, + detailHits, + armed, + current, + ambiguous, + optionId, + onArm, + onCommit +}: { + option: NewWorkspaceProjectOption + nameHits: readonly number[] + detailHits: readonly number[] + armed: boolean + current: boolean + ambiguous: boolean + optionId: string | undefined + onArm: () => void + onCommit: () => void +}): React.JSX.Element { + return ( +
event.preventDefault()} + onMouseMove={onArm} + onClick={onCommit} + className={cn( + // Why: `items-baseline` (not `items-center`) — the name and its smaller + // detail line sit on one baseline, so the two type sizes read as one + // line rather than two boxes centred against each other. + 'flex h-8 cursor-default items-baseline gap-2 rounded-sm px-2 text-sm', + armed && 'bg-accent text-accent-foreground', + current && !armed && 'bg-accent/60' + )} + > + {/* The mark is a dot, not text, so it centres on the row itself. */} + + + + {/* Name keeps up to half the row; the path absorbs the rest so a deep + path can't squeeze the name down to "chec…". */} + + +
+ ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx new file mode 100644 index 00000000000..4b3d0a07ea9 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -0,0 +1,380 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { Popover, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox' +import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' +import { + ConnectHostButton, + HostRowIcon, + NeedsSetupHostIcon, + RunTargetRow +} from './RunTargetComboboxRow' +import { + buildRunTargetRows, + getEphemeralVmLabel, + getRecipeDetail, + RUN_TARGET_ADD_HOST_KEY, + type EphemeralVmRecipeOption +} from './run-target-options' +import { AddHostSubmenuRow, RecipesSubmenuRow } from './RunTargetSubmenus' +import RunTargetField from './RunTargetField' + +type RunTargetComboboxProps = { + hostOptions: readonly ProjectHostSetupOption[] + hostValue: string | null + onHostChange?: (setupId: string) => void + recipes: EphemeralVmRecipeOption[] + recipeValue: string | null + onRecipeChange?: (recipeId: string | null) => void + onAddRemoteServer?: () => void + onAddSshHost?: () => void + onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise | void +} + +const ROOT_ATTRIBUTE = 'data-run-target-combobox-root' + +/** + * Run-target picker, built to match the project picker: the field *is* the + * search, exactly one row is armed and Enter takes it, hovering arms, and the + * "Add host" row is pinned to the popover edge so it survives every state. + * + * Two things the project picker doesn't have: disconnected hosts carry an + * inline Connect action that must not select the row, and two rows open nested + * lists (VM recipes, Add host) rather than committing. + */ +export default function RunTargetCombobox({ + hostOptions, + hostValue, + onHostChange, + recipes, + recipeValue, + onRecipeChange, + onAddRemoteServer, + onAddSshHost, + onConnectHost +}: RunTargetComboboxProps): React.JSX.Element { + const [submenu, setSubmenu] = useState<'recipes' | 'add-host' | null>(null) + // Track in-flight connects per host so one stalling connect never blocks the others. + const [connectingHostIds, setConnectingHostIds] = useState>(() => new Set()) + + const hasAddHost = Boolean(onAddSshHost || onAddRemoteServer) + const deriveRowKeys = useCallback( + (query: string): string[] => + buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }).rows.map((row) => row.key), + [hasAddHost, hostOptions, recipes] + ) + const combobox = useTypeAheadCombobox(deriveRowKeys) + const { query, setQuery, open, setOpen, armedKey, arm, moveArm, inputRef, listId, setListNode } = + combobox + + const { rows, matchedRecipes } = useMemo( + () => buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }), + [hasAddHost, hostOptions, query, recipes] + ) + const readyHostOptions = useMemo( + () => hostOptions.filter((option) => option.kind === 'ready'), + [hostOptions] + ) + const selectedHost = + readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null + const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null + const armedRow = rows.find((row) => row.key === armedKey) ?? rows[0] ?? null + // Only a committed selection paints the field; typing replaces it. + const committed = query.length === 0 && (selectedRecipe !== null || selectedHost !== null) + + // Closing also drops any open submenu, which the shared hook doesn't know about. + const close = useCallback((): void => { + combobox.close() + setSubmenu(null) + }, [combobox]) + + const selectHost = useCallback( + (setupId: string): void => { + onHostChange?.(setupId) + onRecipeChange?.(null) + close() + }, + [close, onHostChange, onRecipeChange] + ) + + const selectRecipe = useCallback( + (recipeId: string): void => { + onRecipeChange?.(recipeId) + close() + }, + [close, onRecipeChange] + ) + + const connectHost = useCallback( + async (option: NeedsSetupProjectHostOption): Promise => { + if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) { + return + } + setConnectingHostIds((current) => new Set(current).add(option.hostId)) + try { + await onConnectHost(option) + } finally { + // Always clear when the connect settles (success, failure, or timeout) + // so the row's spinner stops. + setConnectingHostIds((current) => { + if (!current.has(option.hostId)) { + return current + } + const next = new Set(current) + next.delete(option.hostId) + return next + }) + } + }, + [connectingHostIds, onConnectHost] + ) + + /** Commits a row, or opens its submenu when the row is a submenu row. */ + const activate = useCallback( + (key: string | null): void => { + const row = rows.find((candidate) => candidate.key === key) + if (!row) { + return + } + if (row.kind === 'ready') { + selectHost(row.option.id) + return + } + if (row.kind === 'needs-setup') { + // Not ready: selecting is a no-op, the Connect action is the way forward. + return + } + setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host') + }, + [rows, selectHost] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setOpen(true) + moveArm(event.key === 'ArrowDown' ? 1 : -1) + setSubmenu(null) + return + } + if ((event.key === 'Enter' || event.key === 'ArrowRight') && open) { + event.preventDefault() + activate(armedRow?.key ?? null) + return + } + if (event.key === 'Escape' && (open || query.length > 0)) { + event.preventDefault() + event.stopPropagation() + // A submenu closes first, so Escape backs out one layer at a time. + if (submenu !== null) { + setSubmenu(null) + return + } + close() + } + }, + [activate, armedRow, close, moveArm, open, query, setOpen, submenu] + ) + + const handleOpenChange = useCallback( + (next: boolean): void => { + if (next) { + setOpen(true) + return + } + close() + }, + [close, setOpen] + ) + + const fieldLabel = selectedRecipe + ? `${getEphemeralVmLabel()} / ${selectedRecipe.name}` + : (selectedHost?.label ?? '') + const fieldDetail = selectedRecipe ? getRecipeDetail(selectedRecipe) : (selectedHost?.path ?? '') + + return ( + + { + setQuery(value) + setOpen(true) + setSubmenu(null) + }} + open={open} + onOpenRequest={() => setOpen(true)} + onToggle={() => setOpen(!open)} + committed={committed} + isRecipe={selectedRecipe !== null} + hostId={selectedHost?.hostId ?? null} + label={fieldLabel} + detail={fieldDetail} + listId={listId} + hasArmedRow={armedRow !== null} + inputRef={inputRef} + onKeyDown={handleKeyDown} + /> + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // The field lives in the anchor, not the content, so Radix would see a + // focus/pointer event "outside" and dismiss the instant you tab in. + onFocusOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + onInteractOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + > +
+
+ {rows.filter((row) => row.kind !== 'add-host').length === 0 ? ( +

+ {translate( + 'auto.components.NewWorkspaceComposerCard.noRunTargets', + 'No run targets are ready for this project.' + )} +

+ ) : null} + {rows.map((row) => { + if (row.kind === 'add-host') { + return null + } + const isArmed = armedRow?.key === row.key + const optionId = isArmed ? `${listId}-armed` : undefined + if (row.kind === 'ready') { + return ( + } + label={row.option.label} + detail={row.option.path} + armed={isArmed} + current={selectedRecipe === null && row.option.id === selectedHost?.id} + optionId={optionId} + onArm={() => { + arm(row.key) + setSubmenu(null) + }} + onCommit={() => selectHost(row.option.id)} + /> + ) + } + if (row.kind === 'needs-setup') { + const connecting = connectingHostIds.has(row.option.hostId) + const hasConnect = Boolean(row.option.connectAction && onConnectHost) + return ( + + } + label={row.option.label} + // Why: a Connect button on the row already says the host + // isn't connected, so its detail line only repeats that. + // Rows without the action still need theirs to explain why. + detail={hasConnect ? '' : row.option.detail} + armed={isArmed} + current={false} + dimmed + optionId={optionId} + onArm={() => { + arm(row.key) + setSubmenu(null) + }} + onCommit={() => {}} + trailing={ + row.option.connectAction && onConnectHost ? ( + void connectHost(row.option)} + /> + ) : undefined + } + /> + ) + } + // Recipes submenu row. + return ( + setSubmenu(next ? 'recipes' : null)} + armed={isArmed} + optionId={optionId} + recipes={matchedRecipes} + selectedRecipeId={selectedRecipe?.id ?? null} + onArm={() => { + arm(row.key) + setSubmenu('recipes') + }} + onSelectRecipe={selectRecipe} + /> + ) + })} +
+ {hasAddHost ? ( + setSubmenu(next ? 'add-host' : null)} + armed={armedRow?.key === RUN_TARGET_ADD_HOST_KEY} + optionId={armedRow?.key === RUN_TARGET_ADD_HOST_KEY ? `${listId}-armed` : undefined} + onArm={() => { + arm(RUN_TARGET_ADD_HOST_KEY) + setSubmenu('add-host') + }} + {...(onAddSshHost + ? { + onAddSshHost: () => { + close() + onAddSshHost() + } + } + : {})} + {...(onAddRemoteServer + ? { + onAddRemoteServer: () => { + close() + onAddRemoteServer() + } + } + : {})} + /> + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx new file mode 100644 index 00000000000..fb5fa811f07 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -0,0 +1,175 @@ +import React from 'react' +import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import { ProjectOptionDetail } from './ProjectComboboxRow' +import { translate } from '@/i18n/i18n' + +/** The local machine isn't a server — a monitor glyph reads as "this computer". */ +export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element { + const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server + return +} + +/** + * One run-target row. Shares the Project picker's shape — 32px, label and + * right-aligned detail on one baseline — so the two composer fields read as one + * control. `stacked` switches to a two-line card for the Add-host choices, + * where the description explains what you're picking rather than labelling a + * thing you already know. + */ +export function RunTargetRow({ + icon, + label, + detail, + armed, + current, + optionId, + dimmed = false, + submenu = false, + stacked = false, + onArm, + onCommit, + trailing +}: { + icon: React.ReactNode + label: string + detail: string + armed: boolean + current: boolean + optionId: string | undefined + /** Not-ready hosts are dormant, not errors — quiet them without disabling. */ + dimmed?: boolean + /** Opens a nested list, so it gets a trailing chevron. */ + submenu?: boolean + /** Two-line card: label over description, for rows that need explaining. */ + stacked?: boolean + onArm: () => void + onCommit: () => void + trailing?: React.ReactNode +}): React.JSX.Element { + return ( +
event.preventDefault()} + onMouseMove={onArm} + onClick={onCommit} + className={cn( + 'flex cursor-default gap-2 rounded-sm px-2 text-sm', + stacked ? 'items-center py-1.5' : 'h-8 items-baseline', + armed && 'bg-accent text-accent-foreground', + current && !armed && 'bg-accent/60' + )} + > + {/* Icons are glyphs, not text, so they centre on the row. */} + + {icon} + + {stacked ? ( + + {label} + {detail} + + ) : ( + <> + + {label} + + + + )} + {trailing} + {submenu ? ( + + + + ) : null} +
+ ) +} + +/** Glyph for a host that can't run yet: connecting, broken, or merely dormant. */ +export function NeedsSetupHostIcon({ + hostId, + connecting, + attention +}: { + hostId: ExecutionHostId + connecting: boolean + attention: boolean +}): React.JSX.Element { + if (connecting) { + return + } + if (attention) { + return + } + return +} + +/** Inline Connect action on a disconnected host row. */ +export function ConnectHostButton({ + connecting, + onConnect +}: { + connecting: boolean + onConnect: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetField.tsx b/src/renderer/src/components/new-workspace/RunTargetField.tsx new file mode 100644 index 00000000000..4694eae3070 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetField.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { ChevronDown, Cloud } from 'lucide-react' +import { PopoverAnchor } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { HostRowIcon } from './RunTargetComboboxRow' +import { COMBOBOX_FIELD_SHELL } from './type-ahead-combobox-styles' + +/** + * The run-target field: a text input that *is* the search box, painted over + * with the committed selection so a long label and its path shrink at + * different rates instead of truncating as one flat string. + */ +export default function RunTargetField({ + query, + onQueryChange, + open, + onOpenRequest, + onToggle, + committed, + isRecipe, + hostId, + label, + detail, + listId, + hasArmedRow, + inputRef, + onKeyDown +}: { + query: string + onQueryChange: (value: string) => void + open: boolean + onOpenRequest: () => void + onToggle: () => void + committed: boolean + isRecipe: boolean + hostId: ExecutionHostId | null + label: string + detail: string + listId: string + hasArmedRow: boolean + inputRef: React.RefObject + onKeyDown: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + return ( + +
{ + inputRef.current?.focus() + onOpenRequest() + }} + className={COMBOBOX_FIELD_SHELL} + > + + {committed ? ( + isRecipe ? ( + + ) : hostId ? ( + + ) : null + ) : null} + +
+ onQueryChange(event.target.value)} + onFocus={onOpenRequest} + onKeyDown={onKeyDown} + className={cn( + 'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground', + committed && 'text-transparent caret-foreground' + )} + /> + {committed ? ( + + ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx new file mode 100644 index 00000000000..ebb41f0c462 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx @@ -0,0 +1,192 @@ +import React from 'react' +import { ChevronDown, Cloud, Plus, Server } from 'lucide-react' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { RunTargetRow } from './RunTargetComboboxRow' +import { + getEphemeralVmLabel, + getRecipeDetail, + RUN_TARGET_ADD_HOST_KEY, + type EphemeralVmRecipeOption +} from './run-target-options' +import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' + +const SUBMENU_CONTENT = cn('w-72 p-1', COMBOBOX_POPOVER_SURFACE) + +/** The "Per-Workspace Environment" row and its nested recipe list. */ +export function RecipesSubmenuRow({ + open, + onOpenChange, + armed, + optionId, + recipes, + selectedRecipeId, + onArm, + onSelectRecipe +}: { + open: boolean + onOpenChange: (open: boolean) => void + armed: boolean + optionId: string | undefined + recipes: readonly EphemeralVmRecipeOption[] + selectedRecipeId: string | null + onArm: () => void + onSelectRecipe: (recipeId: string) => void +}): React.JSX.Element { + const [hoveredKey, setHoveredKey] = React.useState(null) + return ( + + +
+ } + label={getEphemeralVmLabel()} + detail={translate( + 'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint', + 'Provision an on-demand environment from a recipe' + )} + armed={armed} + current={selectedRecipeId !== null} + optionId={optionId} + submenu + onArm={onArm} + onCommit={() => onOpenChange(true)} + /> +
+
+ event.preventDefault()} + > + {/* Why: submenu rows track their own hover — without it they were the + only rows in either picker that never highlighted under the pointer. */} +
setHoveredKey(null)} + > + {recipes.map((recipe) => ( + } + label={recipe.name} + detail={getRecipeDetail(recipe)} + armed={hoveredKey === recipe.id} + current={recipe.id === selectedRecipeId} + optionId={undefined} + onArm={() => setHoveredKey(recipe.id)} + onCommit={() => onSelectRecipe(recipe.id)} + /> + ))} +
+
+
+ ) +} + +/** + * "Add host", pinned to the popover edge so it stays reachable in every state — + * scrolled, filtered to nothing, or with no hosts at all. + */ +export function AddHostSubmenuRow({ + open, + onOpenChange, + armed, + optionId, + onArm, + onAddSshHost, + onAddRemoteServer +}: { + open: boolean + onOpenChange: (open: boolean) => void + armed: boolean + optionId: string | undefined + onArm: () => void + onAddSshHost?: () => void + onAddRemoteServer?: () => void +}): React.JSX.Element { + const [hoveredKey, setHoveredKey] = React.useState(null) + const addHostLabel = translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host') + return ( + + +
event.preventDefault()} + onMouseMove={onArm} + onClick={() => onOpenChange(true)} + className={cn( + 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', + armed && 'bg-accent text-accent-foreground' + )} + > + + {addHostLabel} + + + +
+
+ event.preventDefault()} + > +
setHoveredKey(null)}> + {onAddSshHost ? ( + } + label={translate( + 'auto.components.NewWorkspaceComposerCard.addSshHost', + 'Add SSH host' + )} + detail={translate( + 'auto.components.NewWorkspaceComposerCard.addSshHostHint', + 'Use an existing machine over SSH' + )} + armed={hoveredKey === 'ssh'} + current={false} + stacked + optionId={undefined} + onArm={() => setHoveredKey('ssh')} + onCommit={onAddSshHost} + /> + ) : null} + {onAddRemoteServer ? ( + } + label={translate( + 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer', + 'Add Remote Orca Server' + )} + detail={translate( + 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint', + 'Pair another Orca runtime' + )} + armed={hoveredKey === 'remote'} + current={false} + stacked + optionId={undefined} + onArm={() => setHoveredKey('remote')} + onCommit={onAddRemoteServer} + /> + ) : null} +
+
+
+ ) +} + +export { RUN_TARGET_ADD_HOST_KEY } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx index c0414ab484b..fd828b0b202 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx @@ -146,6 +146,31 @@ function renderSmartField(spies: { return input } +function renderEmojiField(value: string, onValueChange: (value: string) => void): HTMLInputElement { + act(() => { + root.render( + + ) + }) + const input = container.querySelector('[data-workspace-name-input="true"]') + if (!input) { + throw new Error('workspace name input not rendered') + } + return input +} + // Why: the guard must short-circuit before the `open && rows.length > 0` // row-select branch, so drive the field into that state — smart mode with a // typed value synchronously yields a highlighted "use this name" row. @@ -235,3 +260,35 @@ describe('SmartWorkspaceNameField IME Enter guard', () => { expect(onPlainEnter).not.toHaveBeenCalled() }) }) + +describe('SmartWorkspaceNameField emoji shortcodes', () => { + it('replaces a completed shortcode as it is typed', () => { + const onValueChange = vi.fn() + const input = renderEmojiField('Launch ', onValueChange) + + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + input, + 'Launch :wink: experiment' + ) + input.setSelectionRange(13, 13) + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) + }) + + expect(onValueChange).toHaveBeenCalledWith('Launch 😉 experiment') + }) + + it('accepts the highlighted shortcode suggestion with Enter', () => { + const onValueChange = vi.fn() + const input = renderEmojiField('Launch :wink', onValueChange) + + act(() => { + input.focus() + input.setSelectionRange(12, 12) + input.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + pressEnter(input) + + expect(onValueChange).toHaveBeenCalledWith('Launch 😉') + }) +}) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index a655d98d1e7..891ec922682 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -88,6 +88,15 @@ import { getGitHubRuntimeRepoId, getGitHubSourceRuntimeTarget } from '@/lib/github-source-runtime-context' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes, + type WorkspaceEmojiReplacement, + type WorkspaceEmojiSuggestion +} from '@/lib/workspace-emoji-shortcodes' +import { WorkspaceEmojiSuggestionPopover } from './WorkspaceEmojiSuggestionPopover' type RepoOption = ReturnType['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] @@ -308,6 +317,8 @@ export default function SmartWorkspaceNameField({ const [branchesLoading, setBranchesLoading] = useState(false) const [linearLoading, setLinearLoading] = useState(false) const [commandValue, setCommandValue] = useState('') + const [emojiCommandValue, setEmojiCommandValue] = useState('') + const [emojiCursor, setEmojiCursor] = useState(null) const localInputRef = useRef(null) const focusedSelectedSourceKeyRef = useRef(null) const tabsListRef = useRef(null) @@ -1104,6 +1115,33 @@ export default function SmartWorkspaceNameField({ isQueryStale, sourceIntent }) + const activeEmojiShortcode = useMemo( + () => getActiveWorkspaceEmojiShortcode(value, emojiCursor), + [emojiCursor, value] + ) + const emojiSuggestions = useMemo( + () => + activeEmojiShortcode + ? searchWorkspaceEmojiShortcodes(activeEmojiShortcode.query) + : ([] as WorkspaceEmojiSuggestion[]), + [activeEmojiShortcode] + ) + const emojiMenuOpen = + !disabled && + selectedSource === null && + activeEmojiShortcode !== null && + emojiSuggestions.length > 0 + const resolvedEmojiCommandValue = emojiSuggestions.some( + (suggestion) => `emoji:${suggestion.shortcode}` === emojiCommandValue + ) + ? emojiCommandValue + : emojiSuggestions[0] + ? `emoji:${emojiSuggestions[0].shortcode}` + : '' + const selectedEmojiSuggestion = + emojiSuggestions.find( + (suggestion) => `emoji:${suggestion.shortcode}` === resolvedEmojiCommandValue + ) ?? null const loading = githubLoading || gitlabLoading || branchesLoading || linearLoading const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search @@ -1128,6 +1166,30 @@ export default function SmartWorkspaceNameField({ [onBranchSelect, onGitHubItemSelect, onGitLabItemSelect, onLinearIssueSelect, onValueChange] ) + const applyEmojiReplacement = useCallback( + (replacement: WorkspaceEmojiReplacement): void => { + onValueChange(replacement.value) + setEmojiCursor(null) + cancelLocalInputFocusFrame() + localInputFocusFrameRef.current = requestAnimationFrame(() => { + localInputFocusFrameRef.current = null + localInputRef.current?.focus({ preventScroll: true }) + localInputRef.current?.setSelectionRange(replacement.cursor, replacement.cursor) + }) + }, + [cancelLocalInputFocusFrame, onValueChange] + ) + + const handleEmojiSelect = useCallback( + (suggestion: WorkspaceEmojiSuggestion): void => { + if (!activeEmojiShortcode) { + return + } + applyEmojiReplacement(applyWorkspaceEmojiSuggestion(value, activeEmojiShortcode, suggestion)) + }, + [activeEmojiShortcode, applyEmojiReplacement, value] + ) + const acceptGitHubLink = useCallback( async (targetRepo: RepoOption): Promise => { if (!crossRepoPrompt) { @@ -1450,8 +1512,20 @@ export default function SmartWorkspaceNameField({ setOpen(true) } }} + onClick={(event) => setEmojiCursor(event.currentTarget.selectionStart)} onChange={(event) => { - onValueChange(event.target.value) + const nextValue = event.target.value + const nextCursor = event.target.selectionStart + const completedEmoji = replaceCompletedWorkspaceEmojiShortcode( + nextValue, + nextCursor + ) + if (completedEmoji) { + applyEmojiReplacement(completedEmoji) + return + } + onValueChange(nextValue) + setEmojiCursor(nextCursor) if (!disabled && mode !== 'text') { markSourcePopoverUserEngaged() setOpen(true) @@ -1460,8 +1534,10 @@ export default function SmartWorkspaceNameField({ onFocus={(event) => { // Why: only open on focus from another composer control (Tab); dialog autofocus from outside stays suppressed. if (!isComposerFieldToFieldFocus(event)) { + setEmojiCursor(event.currentTarget.selectionStart) return } + setEmojiCursor(event.currentTarget.selectionStart) markSourcePopoverUserEngaged() tryOpenSourcePopover() }} @@ -1476,6 +1552,20 @@ export default function SmartWorkspaceNameField({ return } } + if (emojiMenuOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + event.preventDefault() + event.stopPropagation() + const selectedIndex = emojiSuggestions.findIndex( + (suggestion) => + `emoji:${suggestion.shortcode}` === resolvedEmojiCommandValue + ) + const direction = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = + (selectedIndex + direction + emojiSuggestions.length) % + emojiSuggestions.length + setEmojiCommandValue(`emoji:${emojiSuggestions[nextIndex].shortcode}`) + return + } if ( event.key === 'Enter' && !event.metaKey && @@ -1490,6 +1580,12 @@ export default function SmartWorkspaceNameField({ if (isImeCompositionKeyDown(event)) { return } + if (emojiMenuOpen && selectedEmojiSuggestion) { + event.preventDefault() + event.stopPropagation() + handleEmojiSelect(selectedEmojiSuggestion) + return + } if (open && rows.length > 0) { const row = rows.find((entry) => entry.value === resolvedCommandValue) if (row) { @@ -1501,6 +1597,22 @@ export default function SmartWorkspaceNameField({ } onPlainEnter?.() } + if ( + event.key === 'Tab' && + !event.shiftKey && + emojiMenuOpen && + selectedEmojiSuggestion + ) { + event.preventDefault() + event.stopPropagation() + handleEmojiSelect(selectedEmojiSuggestion) + return + } + if (event.key === 'Escape' && emojiMenuOpen) { + event.stopPropagation() + setEmojiCursor(null) + return + } if (event.key === 'Escape' && open) { event.stopPropagation() setOpen(false) @@ -1517,8 +1629,11 @@ export default function SmartWorkspaceNameField({
+ { + if (!next) { + setEmojiCursor(null) + } + }} + /> !next && dismissCrossRepoPrompt()} diff --git a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx b/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx new file mode 100644 index 00000000000..209fd72311c --- /dev/null +++ b/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx @@ -0,0 +1,78 @@ +import type { RefObject } from 'react' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import type { WorkspaceEmojiSuggestion } from '@/lib/workspace-emoji-shortcodes' + +type WorkspaceEmojiSuggestionPopoverProps = { + anchorRef: RefObject + commandValue: string + heading: string + onCommandValueChange: (value: string) => void + onOpenChange: (open: boolean) => void + onSelect: (suggestion: WorkspaceEmojiSuggestion) => void + open: boolean + suggestions: readonly WorkspaceEmojiSuggestion[] +} + +export function WorkspaceEmojiSuggestionPopover({ + anchorRef, + commandValue, + heading, + onCommandValueChange, + onOpenChange, + onSelect, + open, + suggestions +}: WorkspaceEmojiSuggestionPopoverProps): React.JSX.Element { + return ( + + } /> + event.preventDefault()} + onPointerDownOutside={(event) => { + if (anchorRef.current?.contains(event.target as Node)) { + event.preventDefault() + } + }} + onFocusOutside={(event) => { + if (anchorRef.current?.contains(event.target as Node)) { + event.preventDefault() + } + }} + > + + + + {suggestions.map((suggestion) => ( + onSelect(suggestion)} + className="gap-2 px-3 py-2 text-xs" + > + + {suggestion.emoji} + + + :{suggestion.shortcode}: + + + ))} + + + + + + ) +} diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts new file mode 100644 index 00000000000..6e16dae386d --- /dev/null +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import { + getAmbiguousProjectOptionIds, + rankProjectOptions, + sectionProjectOptions, + splitDetailForElision +} from './project-combobox-matching' + +function project(id: string, displayName: string, detail: string): NewWorkspaceProjectOption { + return { kind: 'project', id, projectId: id, displayName, badgeColor: '#111', detail } +} + +const orca = project('orca', 'orca', 'stablyai/orca') +const relay = project('relay', 'orca-relay', 'stablyai/orca-relay') +const gateway = project('gateway', 'api-gateway', 'acme/api-gateway') + +describe('rankProjectOptions', () => { + it('ranks a name-prefix match above a mid-name match', () => { + const ranked = rankProjectOptions([relay, orca], 'orca', []) + expect(ranked[0]?.option.id).toBe('orca') + }) + + it('matches the detail line when the name does not match', () => { + const ranked = rankProjectOptions([gateway], 'acme', []) + expect(ranked).toHaveLength(1) + expect(ranked[0]?.detailHits.length).toBeGreaterThan(0) + }) + + it('matches a scattered subsequence in the name but not in the detail', () => { + const hosts = project('infra', 'infra', '3 hosts configured') + // "sc" appears scattered in "hosts configured" but detail matching is + // substring-only, so this must not match on the detail line. + expect(rankProjectOptions([hosts], 'sc', [])).toHaveLength(0) + expect(rankProjectOptions([hosts], 'ifa', [])).toHaveLength(1) + }) + + it('orders an unfiltered list by recency', () => { + const ranked = rankProjectOptions([orca, relay, gateway], '', ['gateway', 'relay']) + expect(ranked.map((r) => r.option.id)).toEqual(['gateway', 'relay', 'orca']) + }) + + it('returns nothing for an oversized query rather than scanning it', () => { + expect(rankProjectOptions([orca], 'x'.repeat(4096), [])).toEqual([]) + }) +}) + +describe('sectionProjectOptions', () => { + const many = Array.from({ length: 7 }, (_, i) => project(`p${i}`, `proj-${i}`, `acme/proj-${i}`)) + + it('collapses to a single unlabelled list while a query is live', () => { + const matches = rankProjectOptions(many, 'proj', []) + const sections = sectionProjectOptions(matches, 'proj', ['p3']) + expect(sections).toHaveLength(1) + expect(sections[0]?.heading).toBeNull() + }) + + it('splits into Recent/Projects once the list is long enough to warrant it', () => { + const matches = rankProjectOptions(many, '', ['p3', 'p5']) + const sections = sectionProjectOptions(matches, '', ['p3', 'p5']) + expect(sections.map((s) => s.heading)).toEqual(['Recent', 'Projects']) + expect(sections[0]?.items.map((i) => i.option.id)).toEqual(['p3', 'p5']) + }) + + it('keeps a short list unsectioned', () => { + const matches = rankProjectOptions([orca, relay], '', ['relay']) + expect(sectionProjectOptions(matches, '', ['relay'])).toHaveLength(1) + }) +}) + +describe('getAmbiguousProjectOptionIds', () => { + it('flags only ids whose display name repeats', () => { + const a = project('a', 'scratch', '~/code/scratch') + const b = project('b', 'scratch', '~/src/scratch') + const ids = getAmbiguousProjectOptionIds([a, b, orca]) + expect(ids).toEqual(new Set(['a', 'b'])) + }) +}) + +describe('splitDetailForElision', () => { + it('keeps the last two segments so sibling paths stay distinguishable', () => { + const split = splitDetailForElision('~/Developer/work/acme/monorepo/services/checkout-api') + expect(split?.tail).toBe('services/checkout-api') + }) + + it('leaves short or shallow details alone', () => { + expect(splitDetailForElision('stablyai/orca')).toBeNull() + expect(splitDetailForElision('3 hosts configured')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts new file mode 100644 index 00000000000..edbd5595e70 --- /dev/null +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts @@ -0,0 +1,156 @@ +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import { isNewWorkspaceProjectOptionQueryTooLarge } from '@/lib/new-workspace-project-options' + +export type ScoredProjectOption = { + option: NewWorkspaceProjectOption + score: number + nameHits: readonly number[] + detailHits: readonly number[] +} + +function substringHits(text: string, query: string): number[] | null { + const at = text.toLowerCase().indexOf(query) + return at < 0 ? null : Array.from({ length: query.length }, (_, offset) => at + offset) +} + +/** + * Verbatim run, else scattered subsequence. Only names get the loose pass — + * a subsequence over a detail line matches "scr" against "3 hosts configured". + */ +function nameHitsFor(name: string, query: string): number[] | null { + const verbatim = substringHits(name, query) + if (verbatim) { + return verbatim + } + const haystack = name.toLowerCase() + const hits: number[] = [] + let cursor = 0 + for (const char of query) { + const found = haystack.indexOf(char, cursor) + if (found < 0) { + return null + } + hits.push(found) + cursor = found + 1 + } + return hits +} + +function nameScore(name: string, hits: readonly number[]): number { + const first = hits[0] ?? 0 + const contiguous = (hits.at(-1) ?? first) - first === hits.length - 1 + const boundary = first === 0 || /[^a-z0-9]/i.test(name[first - 1] ?? '') + const base = contiguous ? (first === 0 ? 900 : boundary ? 780 : 700) : 420 + return base - name.length * 0.4 +} + +/** + * Ranks options for a query, with recency breaking ties (and ordering the + * unfiltered list). `recentIds` is most-recent-first. + */ +export function rankProjectOptions( + options: readonly NewWorkspaceProjectOption[], + rawQuery: string, + recentIds: readonly string[] +): ScoredProjectOption[] { + if (isNewWorkspaceProjectOptionQueryTooLarge(rawQuery)) { + return [] + } + const query = rawQuery.trim().toLowerCase() + const scored: ScoredProjectOption[] = [] + for (const option of options) { + const recentAt = recentIds.indexOf(option.id) + const recency = recentAt < 0 ? 0 : 32 - recentAt * 4 + if (query.length === 0) { + scored.push({ option, score: recency, nameHits: [], detailHits: [] }) + continue + } + const nameHits = nameHitsFor(option.displayName, query) + const detailHits = substringHits(option.detail, query) + if (!nameHits && !detailHits) { + continue + } + scored.push({ + option, + score: (nameHits ? nameScore(option.displayName, nameHits) : 260) + recency, + nameHits: nameHits ?? [], + detailHits: detailHits ?? [] + }) + } + return scored.sort( + (a, b) => + b.score - a.score || + a.option.displayName.localeCompare(b.option.displayName) || + a.option.detail.localeCompare(b.option.detail) + ) +} + +export type ProjectOptionSection = { + key: 'recent' | 'projects' | 'folders' | 'results' + heading: string | null + items: ScoredProjectOption[] +} + +/** Below this a list is scannable, so sections are chrome rather than help. */ +const SECTION_THRESHOLD = 6 +const RECENT_LIMIT = 4 + +/** + * While a query is live the ranking *is* the order, so sections would fight it: + * everything collapses into one unlabelled result list. + */ +export function sectionProjectOptions( + matches: readonly ScoredProjectOption[], + query: string, + recentIds: readonly string[] +): ProjectOptionSection[] { + if (query.trim() !== '' || matches.length < SECTION_THRESHOLD) { + return [{ key: 'results', heading: null, items: [...matches] }] + } + const recentSet = new Set( + recentIds.filter((id) => matches.some((m) => m.option.id === id)).slice(0, RECENT_LIMIT) + ) + const recent = recentIds.flatMap((id) => + recentSet.has(id) ? matches.filter((m) => m.option.id === id) : [] + ) + const sections: ProjectOptionSection[] = [ + { key: 'recent', heading: 'Recent', items: recent }, + { + key: 'projects', + heading: 'Projects', + items: matches.filter((m) => m.option.kind === 'project' && !recentSet.has(m.option.id)) + }, + { + key: 'folders', + heading: 'Folders', + items: matches.filter((m) => m.option.kind === 'project-group') + } + ] + return sections.filter((section) => section.items.length > 0) +} + +/** Ids whose displayName repeats — those rows can't be read by name alone. */ +export function getAmbiguousProjectOptionIds( + options: readonly NewWorkspaceProjectOption[] +): Set { + const counts = new Map() + for (const option of options) { + counts.set(option.displayName, (counts.get(option.displayName) ?? 0) + 1) + } + return new Set( + options.filter((o) => (counts.get(o.displayName) ?? 0) > 1).map((option) => option.id) + ) +} + +/** + * A deep path's identity lives in its tail (`…/services/checkout-api`), which is + * exactly what a plain truncate throws away — two sibling paths then render + * identically. Split so the head can elide while the tail keeps its width. + */ +export function splitDetailForElision(detail: string): { head: string; tail: string } | null { + const segments = detail.split('/') + if (segments.length <= 3 || detail.length <= 28) { + return null + } + return { head: segments.slice(0, -2).join('/'), tail: segments.slice(-2).join('/') } +} diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts new file mode 100644 index 00000000000..a07a094cf80 --- /dev/null +++ b/src/renderer/src/components/new-workspace/run-target-options.ts @@ -0,0 +1,110 @@ +import type { OrcaHooks } from '../../../../shared/types' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption, + ReadyProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { translate } from '@/i18n/i18n' + +export type EphemeralVmRecipeOption = NonNullable[number] + +export const RUN_TARGET_ADD_HOST_KEY = 'add-host' +export const RUN_TARGET_RECIPES_KEY = 'per-workspace-env' + +/** A row in the run-target list. Hosts commit; the last two open a submenu. */ +export type RunTargetRowModel = + | { key: string; kind: 'ready'; option: ReadyProjectHostSetupOption } + | { key: string; kind: 'needs-setup'; option: NeedsSetupProjectHostOption } + | { key: typeof RUN_TARGET_RECIPES_KEY; kind: 'recipes' } + | { key: typeof RUN_TARGET_ADD_HOST_KEY; kind: 'add-host' } + +export function getEphemeralVmLabel(): string { + return translate( + 'auto.components.NewWorkspaceComposerCard.ephemeralVm', + 'Per-Workspace Environment' + ) +} + +export function getRecipeCommandDisplay(command: string): string { + const trimmed = command.trim() + const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/) + return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed +} + +export function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string { + if (recipe.destroyDisabled) { + return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled') + } + if (recipe.destroy) { + return translate( + 'auto.components.NewWorkspaceComposerCard.destroyConfigured', + 'destroy configured' + ) + } + return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy') +} + +/** One line under a recipe name: what it runs and whether it tears down. */ +export function getRecipeDetail(recipe: EphemeralVmRecipeOption): string { + return `${getRecipeCommandDisplay(recipe.create)} · ${getRecipeDestroyLabel(recipe)}` +} + +function matches(haystack: string, query: string): boolean { + return haystack.toLowerCase().includes(query) +} + +/** + * Filters hosts and recipes by a typed query. The submenu rows survive filtering + * only while they still have something to show — "Add host" always survives, so + * it stays reachable in every state including no-matches. + */ +export function buildRunTargetRows({ + hostOptions, + recipes, + query, + hasAddHost +}: { + hostOptions: readonly ProjectHostSetupOption[] + recipes: readonly EphemeralVmRecipeOption[] + query: string + hasAddHost: boolean +}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } { + const trimmed = query.trim().toLowerCase() + const hostMatches = (option: ProjectHostSetupOption): boolean => + trimmed === '' || + matches(option.label, trimmed) || + matches(option.detail, trimmed) || + (option.kind === 'ready' && matches(option.path, trimmed)) + + const ready = hostOptions.filter( + (option): option is ReadyProjectHostSetupOption => + option.kind === 'ready' && hostMatches(option) + ) + const needsSetup = hostOptions.filter( + (option): option is NeedsSetupProjectHostOption => + option.kind === 'needs-setup' && hostMatches(option) + ) + const matchedRecipes = recipes.filter( + (recipe) => + trimmed === '' || + matches(recipe.name, trimmed) || + matches(getEphemeralVmLabel(), trimmed) || + matches(recipe.description ?? '', trimmed) + ) + + const rows: RunTargetRowModel[] = [ + ...ready.map((option) => ({ key: `host:${option.id}`, kind: 'ready' as const, option })), + ...needsSetup.map((option) => ({ + key: `needs:${option.id}`, + kind: 'needs-setup' as const, + option + })) + ] + if (matchedRecipes.length > 0) { + rows.push({ key: RUN_TARGET_RECIPES_KEY, kind: 'recipes' }) + } + if (hasAddHost) { + rows.push({ key: RUN_TARGET_ADD_HOST_KEY, kind: 'add-host' }) + } + return { rows, matchedRecipes } +} diff --git a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts index 601b838f649..875d4cf1213 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts @@ -26,7 +26,7 @@ describe('smart-workspace-localized-options', () => { 'Linear', 'GitLab', '分支', - '姓名' + '名称' ]) await i18n.changeLanguage('en') diff --git a/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts b/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts new file mode 100644 index 00000000000..71a3122b175 --- /dev/null +++ b/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts @@ -0,0 +1,16 @@ +/** + * Shared recipes for the composer's type-ahead pickers, so Project and Run on + * can't drift apart. + */ + +/** Input-shaped shell without being an `` — the field wraps a bare input. */ +export const COMBOBOX_FIELD_SHELL = + 'flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30' + +/** + * Opaque and unfaded, unlike the shared popover recipe. These land directly on + * the composer dialog, so a translucent fade shows the form underneath + * mid-animation and the open reads as a double flash. + */ +export const COMBOBOX_POPOVER_SURFACE = + 'bg-[var(--popover)] data-[state=closed]:fade-out-100 data-[state=open]:fade-in-100 dark:bg-[var(--popover)]' diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts new file mode 100644 index 00000000000..44fddd40d08 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { orderProjectIdsByRecency } from './use-recent-project-ids' + +function worktree(partial: Partial): Worktree { + return { + id: `${partial.repoId ?? 'repo'}::${partial.path ?? '/tmp/wt'}`, + repoId: partial.repoId ?? 'repo', + displayName: 'wt', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + path: '/tmp/wt', + branch: 'main', + ...partial + } as Worktree +} + +describe('orderProjectIdsByRecency', () => { + it('orders projects by their newest workspace, newest first', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha', createdAt: 10 }), + worktree({ projectId: 'beta', createdAt: 30 }), + worktree({ projectId: 'gamma', createdAt: 20 }) + ]) + expect(ids.filter((id) => !id.includes(':'))).toEqual(['beta', 'gamma', 'alpha']) + }) + + it('uses the newest workspace per project, not the oldest', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha', createdAt: 1 }), + worktree({ projectId: 'alpha', createdAt: 99 }), + worktree({ projectId: 'beta', createdAt: 50 }) + ]) + expect(ids[0]).toBe('alpha') + }) + + it('skips legacy repo-only workspaces that carry no project identity', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: undefined, createdAt: 99 }), + worktree({ projectId: 'beta', createdAt: 1 }) + ]) + expect(ids).toContain('beta') + expect(ids).not.toContain(undefined) + }) + + it('emits the folder-group option id too, so grouped targets resolve', () => { + const ids = orderProjectIdsByRecency([worktree({ projectId: 'alpha', createdAt: 5 })]) + expect(ids).toContain('project-group:alpha') + }) + + it('treats a missing createdAt as oldest rather than crashing', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha' }), + worktree({ projectId: 'beta', createdAt: 5 }) + ]) + expect(ids[0]).toBe('beta') + }) +}) diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts new file mode 100644 index 00000000000..3c68485ac52 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import type { Worktree } from '../../../../shared/types' +import { useAllWorktrees } from '@/store/selectors' +import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options' + +/** + * Most-recently-used project option ids, newest first, derived from when the + * user last created a workspace in each project. Orca stores no explicit + * per-project recency, and workspace creation is exactly the action this picker + * is about to repeat, so it's the honest proxy rather than a new store field. + */ +export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] { + const newestByProject = new Map() + for (const worktree of worktrees) { + const projectId = worktree.projectId + // Legacy repo-only workspaces have no projectId and can't be attributed. + if (projectId === undefined || projectId === '') { + continue + } + const createdAt = worktree.createdAt ?? 0 + const seen = newestByProject.get(projectId) + if (seen === undefined || createdAt > seen) { + newestByProject.set(projectId, createdAt) + } + } + return [...newestByProject.entries()] + .sort((a, b) => b[1] - a[1]) + .flatMap(([projectId]) => [ + projectId, + // Folder-group options carry a prefixed id, so both forms resolve. + `${NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX}${projectId}` + ]) +} + +export function useRecentProjectIds(): string[] { + const worktrees = useAllWorktrees() + return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees]) +} diff --git a/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts b/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts new file mode 100644 index 00000000000..2cf53d56614 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts @@ -0,0 +1,113 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useWheelScrollable } from './use-wheel-scrollable' + +/** + * The machinery shared by the composer's type-ahead pickers (Project, Run on): + * the field is the search box, exactly one row is armed at a time, and Enter + * takes it. + * + * Two subtleties worth keeping: + * - Armed is tracked by row *key*, not index, so a list arriving late over SSH + * cannot slide a different row under a keypress the user already aimed. The + * query it was aimed at rides along, so a newer query drops a stale arm + * during render rather than needing a reset effect. + * - Closing without committing clears the query, or the field is left showing + * text that matches nothing while hiding the committed selection. + */ +export function useTypeAheadCombobox(deriveRowKeys: (query: string) => readonly string[]): { + query: string + setQuery: (value: string) => void + open: boolean + setOpen: (open: boolean) => void + /** Clears the query and closes — the "back out without committing" path. */ + close: () => void + /** Radix `onOpenChange`: closing always drops an uncommitted query. */ + handleOpenChange: (next: boolean) => void + /** Row keys for the current query, as returned by `deriveRowKeys`. */ + rowKeys: readonly string[] + armedKey: string | null + arm: (key: string) => void + /** Moves the arm by `step` rows, clamped to the ends. */ + moveArm: (step: number) => void + inputRef: React.RefObject + listId: string + /** Ref callback for the scroll pane; also restores wheel scrolling. */ + setListNode: (node: HTMLDivElement | null) => void +} { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [armed, setArmed] = useState<{ key: string; query: string } | null>(null) + const rowKeys = deriveRowKeys(query) + const inputRef = useRef(null) + // react-remove-scroll (Radix Dialog) cancels wheel events for this portaled + // pane, so the list has to scroll itself. + const { ref: listRef, setNode: setListNode } = useWheelScrollable() + const listId = React.useId() + + // A stale arm (aimed at an earlier query) falls back to the first row. + const armedKey = armed !== null && armed.query === query ? armed.key : null + const armedIndex = Math.max(armedKey === null ? -1 : rowKeys.indexOf(armedKey), 0) + const resolvedKey = rowKeys[armedIndex] ?? null + + React.useEffect(() => { + if (open) { + listRef.current?.querySelector('[data-armed="true"]')?.scrollIntoView({ block: 'nearest' }) + } + }, [listRef, open, armedIndex, rowKeys.length]) + + const arm = useCallback( + // `query` is read at call time; the closure refreshes each render. + (key: string) => setArmed({ key, query }), + [query] + ) + + const moveArm = useCallback( + (step: number): void => { + const next = rowKeys[Math.min(Math.max(armedIndex + step, 0), rowKeys.length - 1)] + if (next !== undefined) { + setArmed({ key: next, query }) + } + }, + [armedIndex, query, rowKeys] + ) + + const close = useCallback((): void => { + setOpen(false) + setQuery('') + }, []) + + const handleOpenChange = useCallback( + (next: boolean): void => { + if (next) { + setOpen(true) + return + } + close() + }, + [close] + ) + + return useMemo( + () => ({ + query, + setQuery, + open, + setOpen, + close, + handleOpenChange, + rowKeys, + armedKey: resolvedKey, + arm, + moveArm, + inputRef, + listId, + setListNode + }), + [arm, close, handleOpenChange, listId, moveArm, open, query, resolvedKey, rowKeys, setListNode] + ) +} + +/** True when an event target lies inside the control marked by `rootAttribute`. */ +export function isWithinComboboxRoot(target: EventTarget | null, rootAttribute: string): boolean { + return target instanceof Element && target.closest(`[${rootAttribute}="true"]`) !== null +} diff --git a/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts b/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts new file mode 100644 index 00000000000..c2c010e9283 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' + +/** + * Restores mouse-wheel scrolling for a scroll container inside a portaled + * popover, and hands back the node. + * + * Radix Dialog applies react-remove-scroll, which calls preventDefault() on + * wheel events for elements outside the dialog's DOM tree — the scrollbar + * renders and drags fine, but the wheel does nothing. A non-passive listener on + * the container scrolls it manually instead. `ui/command`'s CommandList carries + * the same shim; this is the equivalent for a plain scroll pane. + */ +export function useWheelScrollable(): { + ref: MutableRefObject + setNode: (node: T | null) => void +} { + const ref = useRef(null) + const detachRef = useRef<(() => void) | null>(null) + + const setNode = useCallback((node: T | null): void => { + detachRef.current?.() + detachRef.current = null + ref.current = node + if (!node) { + return + } + const onWheel = (event: WheelEvent): void => { + if (node.scrollHeight <= node.clientHeight) { + return + } + const delta = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * node.clientHeight + : event.deltaY + const max = node.scrollHeight - node.clientHeight + const next = Math.max(0, Math.min(max, node.scrollTop + delta)) + if (next === node.scrollTop) { + // At an edge: let the event bubble so an ancestor can take the scroll. + return + } + event.preventDefault() + event.stopPropagation() + node.scrollTop = next + } + node.addEventListener('wheel', onWheel, { passive: false }) + detachRef.current = () => node.removeEventListener('wheel', onWheel) + }, []) + + useEffect(() => () => detachRef.current?.(), []) + + return { ref, setNode } +} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index a0fb9c00fd2..893e1c38fac 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { createElement, useEffect } from 'react' -import { act } from 'react' +import { createElement, useEffect, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultOnboardingState } from '../../../../shared/constants' diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index b7f812f4b3d..921ecf33f30 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -104,6 +104,9 @@ export function VaultSessionRow({ sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, codexHome: session.codexHome, + // Why: always sent (null when absent) so drop targets can tell "no cwd" + // from "payload predates the repin field". + sessionCwd: session.cwd ?? null, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.envToDelete ? { envToDelete: resumeStartup.envToDelete } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}), diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index d0b4f798a43..2b87618bd5e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import path from 'node:path' -import React, { type ReactNode, useState } from 'react' -import { act } from 'react' +import React, { type ReactNode, useState, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 0dd54d7b7dd..ab2716d1558 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -25,9 +25,9 @@ import { Pencil, SlidersHorizontal, Trash, - X + X, + ExternalLink } from 'lucide-react' -import { ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index cd64ea47dad..f9587ab8a08 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Plug, Files, GitBranch, ListChecks, PanelRight, Workflow } from 'lucide-react' import { useAppStore } from '@/store' -import type { ActiveRightSidebarTab } from '@/store/slices/editor' +import type { ActiveRightSidebarTab, ActivityBarPosition } from '@/store/slices/editor' import { useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' -import type { ActivityBarPosition } from '@/store/slices/editor' import { isFolderRepo } from '../../../../shared/repo-kind' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts index c2dc13a985e..0ee0dfbdd29 100644 --- a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts +++ b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { PluginPanelActionOutcome } from '../../../../shared/plugins/plugin-panel-bridge' -import type { PanelMessageBudget } from '../../../../shared/plugins/plugin-panel-message-budget' +import { + createPanelMessageBudget, + type PanelMessageBudget +} from '../../../../shared/plugins/plugin-panel-message-budget' import { createPanelBridgeMessageHandler } from './plugin-panel-bridge-host' type FakePanelWindow = Window & { postMessage: ReturnType } @@ -105,6 +108,58 @@ describe('createPanelBridgeMessageHandler', () => { ) }) + it('refuses an oversized request without relaying it', () => { + const panelWindow = createFakePanelWindow() + const { handler, callPanelAction } = createHandler(panelWindow) + + handler( + messageEvent( + { + ...VALID_DATA, + params: { padding: 'x'.repeat(128 * 1024) } + }, + panelWindow + ) + ) + + expect(callPanelAction).not.toHaveBeenCalled() + expect(panelWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'orca-panel-action-result', + requestId: 'req-1', + ok: false, + errorCode: 'invalid_request' + }), + '*' + ) + }) + + it('refuses a valid request after malformed and pong traffic exhaust the budget', () => { + const panelWindow = createFakePanelWindow() + const callPanelAction = vi.fn() + const handler = createPanelBridgeMessageHandler({ + sessionToken: SESSION_TOKEN, + getPanelWindow: () => panelWindow, + callPanelAction, + budget: createPanelMessageBudget({ maxMessages: 2 }) + }) + + handler(messageEvent({ type: 'invalid-hostile-message' }, panelWindow)) + handler(messageEvent({ type: 'orca-panel-pong', pingId: 7 }, panelWindow)) + handler(messageEvent(VALID_DATA, panelWindow)) + + expect(callPanelAction).not.toHaveBeenCalled() + expect(panelWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'orca-panel-action-result', + requestId: 'req-1', + ok: false, + errorCode: 'rate_limited' + }), + '*' + ) + }) + it('relays a denial outcome (missing manifest permission) back to the panel', async () => { const panelWindow = createFakePanelWindow() const { handler } = createHandler(panelWindow, { diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts index 2475a7d293e..4bae174619b 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts @@ -68,6 +68,7 @@ export function resolveBlockedCreateReviewNoticeMessage( case 'unsupported_provider': // Why: base_not_on_remote is a create-time hard failure surfaced as an error // result, not an inline-actionable eligibility state, so it is non-clickable. + // falls through case 'base_not_on_remote': case null: return null diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx index 6d3076d5c4f..0c0417a79ce 100644 --- a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx @@ -14,6 +14,7 @@ import { useAppStore } from '@/store' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { translate } from '@/i18n/i18n' import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' +import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' import { getOpenInEntryAvailability, getWorktreeOpenInEntries, @@ -42,7 +43,9 @@ export function SourceControlEntryContextMenu({ onOpenChange, children }: SourceControlEntryContextMenuProps): React.JSX.Element { - const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? []) + const openInApplications = useAppStore( + (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS + ) const settings = useAppStore((s) => s.settings) const fileManagerLabel = getLocalFileManagerLabel() const openInEntries = React.useMemo( diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index 88bac14374b..9558eb9a369 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -3,8 +3,7 @@ import { toast } from 'sonner' import { useConfirmationDialog } from '@/components/confirmation-dialog' import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Repo } from '../../../../shared/types' -import type { GitHubPRMergeMethod } from '../../../../shared/types' +import type { PRInfo, Repo, GitHubPRMergeMethod } from '../../../../shared/types' import { mergeGitHubHostedReview, setGitHubHostedReviewAutoMerge, diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index 8017aea4445..be2f142adff 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React from 'react' -import { act } from 'react' +import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 815a18abba3..1bc838642a0 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -13,10 +13,9 @@ import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' -import type { Repo } from '../../../../shared/types' +import type { Repo, BaseRefSearchResult } from '../../../../shared/types' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' -import type { BaseRefSearchResult } from '../../../../shared/types' import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, resolveSourceControlAiForOperation diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 6f96cd2e1f4..7578112895a 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -57,7 +57,10 @@ import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { SearchableSetting } from './SearchableSetting' import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' -import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' +import { + markLiveCodexSessionsForRestart, + resolveCodexRestartPromptAccountLabel +} from '@/lib/codex-session-restart' import { Dialog, DialogContent, @@ -173,16 +176,6 @@ function getHostRuntimeLabel(): string { : translate('auto.components.settings.AccountsPane.9baf45d071', 'This device') } -function getCodexAccountLabel( - state: CodexRateLimitAccountsState, - accountId: string | null | undefined -): string { - if (accountId == null) { - return 'System default' - } - return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' -} - // Why: the system-default row has no stored identity, so surface the real // ~/.codex login live — the OAuth email when signed in, a clear custom-provider // note for env-key logins, and the generic fallback when signed out. @@ -745,9 +738,39 @@ export function AccountsPane({ action === `reauth:${nextActiveAccountId}`) || (action.startsWith('remove:') && previousActiveAccountId !== nextActiveAccountId) if (shouldPromptRestart) { + // Why: `add` creates the managed home against the machine's own distro, + // so the slot it wrote is the created account's — not this row's, which + // may still say "WSL default". Found by diffing the roster rather than + // by the row's active id, which resolves to null once two distro slots + // are filled and would send the notice to the wrong lane. + const newAccounts = + action === 'adding' + ? next.accounts.filter( + (account) => !codexAccounts.accounts.some((prior) => prior.id === account.id) + ) + : [] + // Why exactly one: an unloaded prior roster makes every account look new, + // and picking one of those would aim the notice at an unrelated lane. + // Falling back to the row is the pre-existing behaviour, not a new risk. + const addedAccount = newAccounts.length === 1 ? newAccounts[0] : undefined void markLiveCodexSessionsForRestart({ - previousAccountLabel: getCodexAccountLabel(codexAccounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) + previousAccountLabel: resolveCodexRestartPromptAccountLabel( + codexAccounts.accounts, + previousActiveAccountId + ), + nextAccountLabel: resolveCodexRestartPromptAccountLabel( + next.accounts, + nextActiveAccountId + ), + // Why: two accounts can share an email, so the labels alone cannot + // tell the store whether this switch lands back on the launch account. + previousAccountId: previousActiveAccountId ?? null, + nextAccountId: nextActiveAccountId ?? null, + // Why: the mutation wrote this row's slot only, so panes on any other + // lane still launch under the account they already had. + target: addedAccount ? getProviderAccountRuntime(addedAccount) : actionRuntime, + // Why: clearing a distro-less WSL row nulls every distro slot at once. + clearsEveryWslDistro: action === 'select:system' }) } } catch (error) { diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 402fa3ba3f4..aa5b5a5694b 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -105,7 +105,7 @@ const PERMISSIONS: PermissionDefinition[] = [ get description() { return translate( 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8', - 'Recommended when projects, worktrees, or symlinked files touch macOS-protected folders.' + "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca and Orca Helper to reduce those prompts. Then quit Orca, end any surviving Orca Helper process, and reopen Orca." ) }, actionLabel: 'Open Settings', diff --git a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx index 69d420bbdca..e4f5adaa47f 100644 --- a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx @@ -35,7 +35,7 @@ type EmulatorAvailability = { type MobileEmulatorSettingsPaneProps = { settings: GlobalSettings - updateSettings: (updates: Partial) => void + updateSettings: (updates: Partial) => Promise } const AUTOMATIC_DEVICE_VALUE = '__orca_automatic_emulator_device__' diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index c1f4390e5a8..6ca17d9fca3 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React from 'react' -import { act } from 'react' +import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/types' diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index 8bfacae4ad1..d0ee90eb03c 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -21,9 +21,8 @@ import { getTerminalWindowSearchEntries } from './terminal-search' import { Button } from '../ui/button' -import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls' +import { SettingsRow, SettingsSubsectionHeader, FontAutocomplete } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' -import { FontAutocomplete } from './SettingsFormControls' import { TerminalFontSizeSetting } from './TerminalFontSizeSetting' import { TerminalAdvancedTypographyControls } from './TerminalAdvancedTypographyControls' import { TerminalThemeCatalogSection } from './TerminalThemeSections' diff --git a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx index acd8409e74d..31c1595a937 100644 --- a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx @@ -1,7 +1,10 @@ import { useState } from 'react' import { toast } from 'sonner' -import type { VoiceSettings } from '../../../../shared/speech-types' -import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types' +import type { + VoiceSettings, + SpeechModelManifest, + SpeechModelState +} from '../../../../shared/speech-types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { diff --git a/src/renderer/src/components/settings/repository-source-control-ai-labels.ts b/src/renderer/src/components/settings/repository-source-control-ai-labels.ts index 7255b3e8bee..64f0a111080 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-labels.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-labels.ts @@ -5,10 +5,12 @@ import { type SourceControlActionId, type SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' -import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import type { + RepoSourceControlAiOverrides, + SourceControlAiSettings +} from '../../../../shared/source-control-ai-types' import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec' import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec' -import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' export const ACTION_MODE_INHERIT = 'inherit' export const ACTION_MODE_OVERRIDE = 'override' diff --git a/src/renderer/src/components/settings/settings-search-keywords.ts b/src/renderer/src/components/settings/settings-search-keywords.ts index d9ee3003375..4a14a6862a2 100644 --- a/src/renderer/src/components/settings/settings-search-keywords.ts +++ b/src/renderer/src/components/settings/settings-search-keywords.ts @@ -1,5 +1,4 @@ -import { i18n } from '@/i18n/i18n' -import { translate } from '@/i18n/i18n' +import { i18n, translate } from '@/i18n/i18n' export type SearchKeywordSpec = { key: string diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 42e7ee20f82..93fee95eb45 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -80,7 +80,7 @@ vi.mock('@/components/ui/context-menu', () => ({ ) })) -import { +import SidebarNav, { getSetupGuideSidebarEntryReady, shouldShowAgentDashboardButton, shouldShowAgentsButton, @@ -88,7 +88,6 @@ import { shouldShowMobileButton, shouldShowSetupGuideEntry } from './SidebarNav' -import SidebarNav from './SidebarNav' function gitRepo(): Repo { return { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index ca60645185c..3887c52c79d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { act, type ReactNode } from 'react' -import React from 'react' +import React, { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index 161694086f3..7bbcb23b847 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -12,10 +12,33 @@ import { hasWorktreeParentLink, isWorktreeParentPickerDisabled, planWorkspaceStatusAssignment, - selectMenuScopedMap + selectMenuScopedMap, + shouldRevealWorktreeDeveloperMenu } from './WorktreeContextMenu' import type { Worktree, WorktreeLineage, WorkspaceStatusDefinition } from '../../../../shared/types' +describe('shouldRevealWorktreeDeveloperMenu', () => { + it('stays hidden for an ordinary right-click', () => { + expect( + shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed: false, isMultiContext: false }) + ).toBe(false) + }) + + it('reveals when Option/Alt was held at open time', () => { + expect( + shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed: true, isMultiContext: false }) + ).toBe(true) + }) + + // Why: the parking action targets one workspace, so it must not appear for a + // multi-select context even with the modifier held. + it('stays hidden for a multi-workspace selection', () => { + expect( + shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed: true, isMultiContext: true }) + ).toBe(false) + }) +}) + describe('selectMenuScopedMap (delete-teardown re-render guard)', () => { // Why: the closed menu wrapper must stay inert to delete teardown's high-churn // set()s. The guard is referential stability — when closed, the selector must diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index ebaf99d2237..898e406ed8f 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -104,6 +104,16 @@ export function selectMenuScopedMap(menuOpen: boolean, live: T, empty: T): T return menuOpen ? live : empty } +// Why: the Developer submenu is hidden by default and revealed only by holding +// Option/Alt at right-click. altKey is the same physical key on every platform +// (Option on macOS, Alt on Windows/Linux), so no platform branch is needed. +export function shouldRevealWorktreeDeveloperMenu(args: { + developerMenuRevealed: boolean + isMultiContext: boolean +}): boolean { + return args.developerMenuRevealed && !args.isMultiContext +} + export function hasWorktreeParentLink( worktree: Worktree, lineageById: AppState['worktreeLineageById'], @@ -331,6 +341,11 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const repo = useRepoById(worktree.repoId) const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const [menuOpen, setMenuOpen] = useState(false) + // Why: the Developer submenu is a power-user affordance, so it is revealed by + // holding Option/Alt at right-click — captured at open time (like the Help + // menu's admin options) so the submenu can't appear or vanish mid-menu and + // shift the rows under the pointer. + const [developerMenuRevealed, setDeveloperMenuRevealed] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [contextWorktrees, setContextWorktrees] = useState( effectiveSelectedWorktrees @@ -463,6 +478,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const setMenuOpenState = useCallback( (open: boolean) => { setMenuOpen(open) + if (!open) { + // Why: the reveal is per-open, so a later plain right-click can't inherit it. + setDeveloperMenuRevealed(false) + } onOpenChange?.(open) }, [onOpenChange] @@ -731,6 +750,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ event.preventDefault() contextMenuOpenedAtRef.current = Date.now() window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setDeveloperMenuRevealed(event.altKey) setContextWorktrees(onContextMenuSelect?.(event) ?? effectiveSelectedWorktrees) const bounds = event.currentTarget.getBoundingClientRect() setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }) @@ -925,7 +945,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ ) : null} - {!isMultiContext ? ( + {shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed, isMultiContext }) ? ( <> diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx new file mode 100644 index 00000000000..7816939efb2 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +/** + * Why a render test on top of shouldRevealWorktreeDeveloperMenu's unit tests: + * the shipped bug was the submenu rendering unconditionally, i.e. a WIRING + * defect. These assert the modifier actually reaches the reveal state through + * the real right-click handler. + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/types' + +vi.mock('@/store', () => { + const state = { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + workspaceStatuses: [], + projectGroups: [], + settings: null, + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + openModal: vi.fn(), + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteFolderWorkspace: vi.fn(), + setActiveWorktree: vi.fn() + } + return { + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) + } +}) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: () => null, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +// Why: Radix portals/submenus need real layout; a passthrough keeps the test on +// the reveal decision rather than menu mechanics. +vi.mock('@/components/ui/dropdown-menu', () => { + const passthrough = ({ children }: { children?: ReactNode }) => <>{children} + return { + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: passthrough, + DropdownMenuLabel: passthrough, + DropdownMenuRadioGroup: passthrough, + DropdownMenuRadioItem: passthrough, + DropdownMenuSeparator: () => null, + DropdownMenuSub: passthrough, + DropdownMenuSubContent: passthrough, + DropdownMenuSubTrigger: passthrough, + DropdownMenuTrigger: passthrough + } +}) + +vi.mock('./WorktreeOpenInMenu', () => ({ WorktreeOpenInSubMenu: () => null })) +vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null })) +vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null })) +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('./delete-worktree-flow', () => ({ + runWorktreeBatchDelete: vi.fn(), + runWorktreeDelete: vi.fn() +})) +vi.mock('./sleep-worktree-flow', () => ({ runSleepWorktrees: vi.fn() })) + +const WorktreeContextMenu = (await import('./WorktreeContextMenu')).default + +function makeWorktree(): Worktree { + return { + id: 'repo-1::/repo/wt', + repoId: 'repo-1', + path: '/repo/wt', + displayName: 'wt', + branch: 'wt', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } as Worktree +} + +// Why: assert on text content — the passthrough menu mock renders each label as +// a bare text node beside its icon, which getByText's element matching skips. +function openContextMenu(altKey: boolean): string { + const { container } = render( + +
card
+
+ ) + fireEvent.contextMenu(screen.getByTestId('card'), { altKey }) + return container.textContent ?? '' +} + +describe('Developer submenu reveal', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('hides the Developer submenu on a plain right-click', () => { + const text = openContextMenu(false) + + expect(text).toContain('Sleep') + expect(text).not.toContain('Developer') + expect(text).not.toContain('Park terminal') + }) + + it('reveals the Developer submenu when Option/Alt is held', () => { + const text = openContextMenu(true) + + expect(text).toContain('Developer') + expect(text).toContain('Park terminal') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx index 0d6cf3245b4..d5242211256 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx @@ -13,6 +13,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { getExternalEditorOpenCapability } from '@/lib/external-editor-open-capability' +import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' import type { ShellOpenExternalEditorResult } from '../../../../shared/shell-open-types' import type { GlobalSettings, OpenInApplication } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -34,7 +35,7 @@ export type OpenInMenuEntry = { } export function getWorktreeOpenInEntries( - openInApplications: OpenInApplication[], + openInApplications: readonly OpenInApplication[], fileManagerLabel: string ): OpenInMenuEntry[] { return [ @@ -302,7 +303,9 @@ export function WorktreeOpenInMenuItems({ labelPrefix = '' }: WorktreeOpenInMenuItemsProps): React.JSX.Element { const openInWorktreePath = useOpenInWorktreePath({ worktreePath, connectionId }) - const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? []) + const openInApplications = useAppStore( + (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS + ) const settings = useAppStore((s) => s.settings) const fileManagerLabel = getLocalFileManagerLabel() const entries = getWorktreeOpenInEntries(openInApplications, fileManagerLabel) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 89cb89eb534..2c321685ff6 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -65,7 +65,10 @@ import { UsageRosterPanel, getTightestUsageSection } from './UsageRosterPanel' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' -import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' +import { + markLiveCodexSessionsForRestart, + resolveCodexRestartPromptAccountLabel +} from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment' @@ -168,16 +171,6 @@ function getHostRuntimeLabel(): string { return navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' } -function getCodexAccountLabel( - state: CodexRateLimitAccountsState, - accountId: string | null | undefined -): string { - if (accountId == null) { - return 'System default' - } - return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' -} - function getCodexAccountDisplayLabel(account: CodexStatusAccount): string { return account.workspaceLabel ? `${account.email} (${account.workspaceLabel})` : account.email } @@ -1466,8 +1459,23 @@ export function CodexSwitcherMenu({ const nextActiveAccountId = getCodexStatusActiveId(next, target) if (previousActiveAccountId !== nextActiveAccountId) { await markLiveCodexSessionsForRestart({ - previousAccountLabel: getCodexAccountLabel(accountState, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) + previousAccountLabel: resolveCodexRestartPromptAccountLabel( + accountState.accounts, + previousActiveAccountId + ), + nextAccountLabel: resolveCodexRestartPromptAccountLabel( + next.accounts, + nextActiveAccountId + ), + // Why: two accounts can share an email, so the labels alone cannot + // tell the store whether this switch lands back on the launch account. + previousAccountId: previousActiveAccountId ?? null, + nextAccountId: nextActiveAccountId ?? null, + // Why: the mutation wrote this row's slot only, so panes on any other + // lane still launch under the account they already had. + target, + // Why: clearing a distro-less WSL row nulls every distro slot at once. + clearsEveryWslDistro: accountId === null }) // Why: collapse to the summary row (not close) so the follow-up "restart open tabs" prompt appears in the same flow. if (mountedRef.current) { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index fecb07d0d48..db585ab64f7 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -197,6 +197,46 @@ export function barColor(usedPct: number): string { return 'bg-red-500' } +function ProviderRateLimitWindowSection({ + window, + label, + textClass, + mutedClass, + emptyBarClass, + usagePercentageDisplay +}: { + window: RateLimitWindow | null + label: string + textClass: string + mutedClass: string + emptyBarClass: string + usagePercentageDisplay: UsagePercentageDisplay +}): React.JSX.Element | null { + if (!window) { + return null + } + const usedPct = clampUsedPercent(window.usedPercent) + const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) + const resetLabel = window.resetsAt ? formatResetCountdown(window.resetsAt - Date.now()) : null + + return ( +
+
{label}
+
+ {/* Why: fill follows the selected percentage; color still signals consumption urgency. */} +
+
+
+ {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} + {resetLabel && {resetLabel}} +
+
+ ) +} + export function ProviderPanel({ p, inverted = false, @@ -268,38 +308,6 @@ export function ProviderPanel({ ? formatResetCreditExpiry(p.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null - const PanelWindowSection = ({ - w, - label - }: { - w: RateLimitWindow | null - label: string - }): React.JSX.Element | null => { - if (!w) { - return null - } - const usedPct = clampUsedPercent(w.usedPercent) - const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) - const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null - - return ( -
-
{label}
-
- {/* Why: fill follows the selected percentage; color still signals consumption urgency. */} -
-
-
- {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} - {resetLabel && {resetLabel}} -
-
- ) - } - return (
@@ -328,7 +336,15 @@ export function ProviderPanel({
{getWindowSections(p).map((s) => ( - + ))} {p.error ? ( diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 87d12c71218..d2e086f7496 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -10,7 +10,10 @@ import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' -import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { + DEFAULT_DISABLED_TUI_AGENTS, + filterEnabledTuiAgents +} from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' export type QuickLaunchAgentMenuItemsProps = { @@ -107,7 +110,9 @@ function QuickLaunchAgentMenuItemsInner({ const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) const { detectedIds } = useDetectedAgents(agentDetectionTarget) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) - const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) + const disabledAgents = useAppStore( + (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS + ) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index e9eb7a96f30..2ebecc589c9 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -207,7 +207,7 @@ function getTabLayoutSignature( return `${item.type}:${item.id}:${item.isPinned}:${isExpanded}:${Boolean(item.data.color)}:${label}` } if (item.type === 'browser') { - return `${item.type}:${item.id}:${item.isPinned}:${item.data.loading}:${item.data.loadError}:${label}` + return `${item.type}:${item.id}:${item.isPinned}:${item.data.loading}:${Boolean(item.data.loadError)}:${label}` } if (item.type === 'editor') { return `${item.type}:${item.id}:${item.isPinned}:${item.data.isDirty}:${item.data.isPreview}:${item.data.externalMutation ?? ''}:${status ?? ''}:${label}` diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 3bf3e2ea0ac..3c9ddd26369 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -12,13 +12,17 @@ import { hasAiVaultSessionDragData, readAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' -import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command' +import { + buildAiVaultDropRepinStartup, + getAiVaultAgentProviderSession +} from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' +import { aiVaultSessionNeedsResumePreparation } from '@/lib/ai-vault-session-resume-preparation' import { useAppStore } from '@/store' import { resolveDropZone } from './tab-drop-zone' import type { TabDropZone } from './useTabDragSplit' import { translate } from '@/i18n/i18n' -import { isLegacySharedCodexHome } from '../../../../shared/ai-vault-resume-preparation' +import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault-resume-preparation' type PaneDropTarget = { groupId: string @@ -210,23 +214,42 @@ export default function AiVaultSessionDropLayer({ ) } const preparation = - payload.agent === 'codex' && - isLegacySharedCodexHome(payload.codexHome ?? null) && payload.sessionFilePath && payload.sessionExecutionHostId && - payload.codexHome !== undefined + payload.codexHome !== undefined && + aiVaultSessionNeedsResumePreparation({ + agent: payload.agent, + codexHome: payload.codexHome, + executionHostId: payload.sessionExecutionHostId + }) ? window.api.aiVault.prepareSessionResume({ agent: payload.agent, filePath: payload.sessionFilePath, executionHostId: payload.sessionExecutionHostId, codexHome: payload.codexHome }) - : Promise.resolve({ useRealCodexHome: false }) + : Promise.resolve({ useRealCodexHome: false }) void preparation .then((result) => { - const startup = result.useRealCodexHome ? payload.realHomeStartup : payload + const startup = result.useRealCodexHome + ? payload.realHomeStartup + : result.substituteCodexHome + ? buildAiVaultDropRepinStartup({ + state: useAppStore.getState(), + payload, + substituteCodexHome: result.substituteCodexHome, + worktreeId + }) + : payload if (!startup) { - throw new Error('Orca could not prepare this legacy Codex session. Retry resume.') + // Why: the host just proved the prebuilt command pins another + // account's home, so an unrepinnable payload (older serializer) + // must fail loudly rather than silently resume under it. + throw new Error( + result.substituteCodexHome + ? 'This session was dragged from an older Orca window, so Orca cannot retarget it to the selected Codex account. Resume it from the Session History panel instead.' + : 'Orca could not prepare this legacy Codex session. Retry resume.' + ) } const providerSession = getAiVaultAgentProviderSession({ agent: payload.agent, diff --git a/src/renderer/src/components/tab-group/tab-drag-data.ts b/src/renderer/src/components/tab-group/tab-drag-data.ts new file mode 100644 index 00000000000..ad55f0edd10 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-data.ts @@ -0,0 +1,56 @@ +import type { TabGroup, TuiAgent } from '../../../../shared/types' +import type { TabSplitDirection } from '../../store/slices/tabs' + +export type TabDropZone = 'center' | TabSplitDirection + +export type TabDragItemData = { + kind: 'tab' + worktreeId: string + groupId: string + unifiedTabId: string + visibleTabId: string + tabType: 'terminal' | 'editor' | 'browser' | 'simulator' + label: string + iconPath?: string + color?: string | null + agent?: TuiAgent | null +} + +export type TabPaneDropData = { + kind: 'pane-body' + worktreeId: string + groupId: string +} + +export function canDropTabIntoPaneBody({ + activeDrag, + groupsByWorktree, + overGroupId, + worktreeId +}: { + activeDrag: TabDragItemData | null + groupsByWorktree: Record + overGroupId: string + worktreeId: string +}): boolean { + if (!activeDrag || activeDrag.worktreeId !== worktreeId) { + return false + } + + const overGroup = (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === overGroupId) + if (!overGroup) { + return false + } + + return activeDrag.groupId !== overGroupId || overGroup.tabOrder.length > 1 +} + +export function isTabDragData(value: unknown): value is TabDragItemData { + return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab' +} + +export function isPaneDropData(value: unknown): value is TabPaneDropData { + return ( + Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body' + ) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-target.ts b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts index b4c36a0dcdc..7b035ea2f5e 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-target.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts @@ -1,5 +1,4 @@ -import type { TabDragItemData } from './useTabDragSplit' -import { isPaneDropData, isTabDragData } from './useTabDragSplit' +import { isPaneDropData, isTabDragData, type TabDragItemData } from './tab-drag-data' export type DragPreviewTabTarget = { groupId: string diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.ts b/src/renderer/src/components/tab-group/tab-drop-zone.ts index 10291e2389c..a30c592bc5a 100644 --- a/src/renderer/src/components/tab-group/tab-drop-zone.ts +++ b/src/renderer/src/components/tab-group/tab-drop-zone.ts @@ -1,4 +1,4 @@ -import type { TabDropZone } from './useTabDragSplit' +import type { TabDropZone } from './tab-drag-data' /** Matches TabGroupPanel tab row height (`h-[32px]`). */ export const TAB_GROUP_TAB_STRIP_HEIGHT_PX = 32 diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts index b78f383f26c..80fefad4080 100644 --- a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts @@ -11,7 +11,7 @@ import { isPaneDropData, isTabDragData, type TabDragItemData -} from './useTabDragSplit' +} from './tab-drag-data' export type TabGroupPanelGeometryEntry = { groupId: string diff --git a/src/renderer/src/components/tab-group/tab-insertion.ts b/src/renderer/src/components/tab-group/tab-insertion.ts index 708e4616b19..5ae5424036a 100644 --- a/src/renderer/src/components/tab-group/tab-insertion.ts +++ b/src/renderer/src/components/tab-group/tab-insertion.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core' -import type { TabDragItemData } from './useTabDragSplit' +import type { TabDragItemData } from './tab-drag-data' // Why: when a tab is dragged over another tab's sortable rect, we compute // which side of the hovered tab the drop will land on (before vs. after). diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 090e41b047f..ce79cf03281 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -14,9 +14,8 @@ import { useSensor, useSensors } from '@dnd-kit/core' -import type { TabGroup, TuiAgent } from '../../../../shared/types' +import type { TabGroup } from '../../../../shared/types' import { useAppStore } from '../../store' -import type { TabSplitDirection } from '../../store/slices/tabs' import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror' import { resolveTabInsertion, @@ -40,79 +39,34 @@ import { type ActivePaneColumnSplitTarget, type TabGroupPanelGeometrySnapshot } from './tab-group-panel-split-target' +import { + canDropTabIntoPaneBody, + isPaneDropData, + isTabDragData, + type TabDragItemData, + type TabDropZone +} from './tab-drag-data' export type { HoveredTabInsertion } - -export type TabDropZone = 'center' | TabSplitDirection +export { + canDropTabIntoPaneBody, + isPaneDropData, + isTabDragData, + type TabDragItemData, + type TabDropZone, + type TabPaneDropData +} from './tab-drag-data' // Why: tab activation waits for pointerup, so dnd-kit needs enough movement // tolerance to avoid treating ordinary click jitter as an intentional drag. export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12 -export type TabDragItemData = { - kind: 'tab' - worktreeId: string - groupId: string - unifiedTabId: string - visibleTabId: string - tabType: 'terminal' | 'editor' | 'browser' | 'simulator' - /** Rendered by the DragOverlay ghost that follows the cursor across - * groups. Source tab strips use overflow-hidden, so without the overlay - * the dragged tab would be invisible once the cursor leaves its own - * group's strip. */ - label: string - iconPath?: string - color?: string | null - /** Coding-harness agent running in a terminal tab, so the drag ghost shows - * the provider glyph and matches the resting tab. Resolved per-tab in - * SortableTab (not at the TabBar level) to avoid re-rendering the whole tab - * strip on every agent-status ping. */ - agent?: TuiAgent | null -} - -export type TabPaneDropData = { - kind: 'pane-body' - worktreeId: string - groupId: string -} - export type HoveredTabDropTarget = { groupId: string zone: TabDropZone panelRect?: DOMRect } -export function canDropTabIntoPaneBody({ - activeDrag, - groupsByWorktree, - overGroupId, - worktreeId -}: { - activeDrag: TabDragItemData | null - groupsByWorktree: Record - overGroupId: string - worktreeId: string -}): boolean { - if (!activeDrag || activeDrag.worktreeId !== worktreeId) { - return false - } - - const overGroup = (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === overGroupId) - if (!overGroup) { - return false - } - - // Why: splitting the only tab in a group onto that same group's body is a - // visual no-op. The store already rejects that drop, so the hover layer must - // suppress the pane overlay too or the user sees a split affordance that can - // never produce a layout change. - if (activeDrag.groupId === overGroupId && overGroup.tabOrder.length <= 1) { - return false - } - - return true -} - export function canDropTabForPaneColumnSplit(args: { activeDrag: TabDragItemData | null groupsByWorktree: Record @@ -130,16 +84,6 @@ export function canDropTabForPaneColumnSplit(args: { }) } -export function isTabDragData(value: unknown): value is TabDragItemData { - return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab' -} - -export function isPaneDropData(value: unknown): value is TabPaneDropData { - return ( - Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body' - ) -} - const collisionDetection: CollisionDetection = (args) => { const pointerCollisions = pointerWithin(args) return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index bdc52755af7..d52b1dc300f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1,5 +1,14 @@ /* eslint-disable max-lines -- Why: terminal pane component co-locates title state, layout serialization, and portal rendering to keep pane lifecycle consistent. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { useShallow } from 'zustand/react/shallow' import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' @@ -244,6 +253,10 @@ type TerminalPaneProps = { onInitialRenderSettled?: () => void } +export type TerminalPaneHandle = { + closeActivePane: () => void +} + type TerminalQuickCommandEditorDialogProps = { command: TerminalQuickCommand onOpenChange: (open: boolean) => void @@ -274,19 +287,22 @@ function formatClipboardImagePasteError(error: unknown): string { return `Image paste failed: ${detail}` } -export default function TerminalPane({ - tabId, - worktreeId, - cwd, - isActive, - isVisible = true, - isWorktreeActive = isVisible, - isolatedPaneKey = null, - showSplitButton = true, - onPtyExit, - onCloseTab, - onInitialRenderSettled -}: TerminalPaneProps): React.JSX.Element { +function TerminalPane( + { + tabId, + worktreeId, + cwd, + isActive, + isVisible = true, + isWorktreeActive = isVisible, + isolatedPaneKey = null, + showSplitButton = true, + onPtyExit, + onCloseTab, + onInitialRenderSettled + }: TerminalPaneProps, + ref: React.ForwardedRef +): React.JSX.Element { const containerRef = useRef(null) const managerRef = useRef(null) const paneFontSizesRef = useRef>(new Map()) @@ -1321,6 +1337,20 @@ export default function TerminalPane({ [executeClosePane, tabId, worktreeId, getCloseDialogCopyKind] ) + useImperativeHandle( + ref, + () => ({ + closeActivePane: (): void => { + const manager = managerRef.current + const pane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (pane) { + handleRequestClosePane(pane.id) + } + } + }), + [handleRequestClosePane] + ) + const handleSearchSelectedText = useCallback((selectedText: string): void => { const state = useAppStore.getState() state.showRightSidebarSearch({ query: selectedText }) @@ -3200,3 +3230,5 @@ export default function TerminalPane({ ) } + +export default forwardRef(TerminalPane) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 2578d657569..357ac5db0d4 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -50,7 +50,8 @@ export function resolveTerminalKeyboardShortcutAction( isKittyKeyboardActivePane: Parameters[7], layoutBaseCharacterForCode: Parameters[8], getWindowsShiftEnterEncoding: Parameters[9], - isWindowsTerminalHost: NonNullable[10]> + isWindowsTerminalHost: NonNullable[10]>, + terminalShortcutPolicy: Parameters[11] = 'orca-first' ): ReturnType { // Why: keep the host callback required at the production boundary so a // caller cannot silently fall back to client-OS byte routing. @@ -65,7 +66,8 @@ export function resolveTerminalKeyboardShortcutAction( isKittyKeyboardActivePane, layoutBaseCharacterForCode, getWindowsShiftEnterEncoding, - isWindowsTerminalHost + isWindowsTerminalHost, + terminalShortcutPolicy ) } @@ -397,7 +399,8 @@ export function useTerminalKeyboardShortcuts({ isKittyKeyboardActivePane, getLayoutBaseCharacterForCode, getActivePaneWindowsShiftEnterEncoding, - isActivePaneWindowsTerminalHost + isActivePaneWindowsTerminalHost, + terminalShortcutPolicy ) if (!action) { return @@ -666,6 +669,7 @@ export function useTerminalKeyboardShortcuts({ keyboardScopeRef, managerRef, paneTransportsRef, + panePtyBindingsRef, paneCwdRef, fallbackCwd, expandedPaneIdRef, diff --git a/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts new file mode 100644 index 00000000000..07f16fcbeb4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { canManuallyParkTerminalWorktreeRenderers } from './manual-terminal-worktree-park-eligibility' + +describe('canManuallyParkTerminalWorktreeRenderers', () => { + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true, + hasLivePty: () => true + } + + it('bypasses visibility timing for snapshot-backed local terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + // Why: first activation stamps pendingActivationSpawn on every tab and only a + // fresh updateTabPtyId consumes it, so a reattached tab keeps it forever — + // that residue must not permanently refuse a manual park. + it('ignores a settled activation-spawn tag once the tab has a live PTY', () => { + const terminalTabs = [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true as const } + ] + + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs })).toBe(true) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs, + hasLivePty: () => false + }) + ).toBe(false) + }) + + it('still refuses a multi-pane activation spawn that has not finished', () => { + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ], + hasLivePty: () => false + }) + ).toBe(false) + }) + + it('preserves safety gates for empty, pending, remote, and disabled terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] } + }) + ).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }] + }) + ).toBe(false) + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts new file mode 100644 index 00000000000..b5597dea6a1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts @@ -0,0 +1,56 @@ +/** + * Eligibility for the on-demand ("Park terminal") path. + * + * Why split from terminal-hidden-view-parking: that module owns the automatic + * cold-park policy — the hysteresis clock, hot-retain cap, and recheck + * deadlines. Manual parking reuses its safety gates but deliberately bypasses + * every time-based one, so the divergence lives here instead of accruing + * `nowMs: 0` special cases inside the policy module. + */ +import { + canParkTerminalWorktreeRenderers, + type ColdParkableTerminalTab +} from './terminal-hidden-view-parking' + +// Why: pendingActivationSpawn is sidebar-sort suppression, not a spawn-in-flight +// signal. First activation stamps it on every tab and only a fresh +// updateTabPtyId consumes it, so a tab whose PTY was reattached (not respawned) +// keeps it forever. The automatic path can wait it out; a manual park must not +// be refused by that residue, so drop it wherever the tab already has a live +// PTY — the only trustworthy "spawn settled" signal (tab.ptyId is a wake hint). +function withoutSettledActivationSpawn( + tabs: readonly ColdParkableTerminalTab[], + hasLivePty: (tabId: string) => boolean +): ColdParkableTerminalTab[] { + return tabs.map((tab) => { + if (!tab.pendingActivationSpawn || !hasLivePty(tab.id)) { + return tab + } + const { pendingActivationSpawn: _settled, ...rest } = tab + return rest + }) +} + +export function canManuallyParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + hasLivePty: (tabId: string) => boolean +}): boolean { + return ( + args.terminalTabs.length > 0 && + canParkTerminalWorktreeRenderers({ + ...args, + terminalTabs: withoutSettledActivationSpawn(args.terminalTabs, args.hasLivePty), + // Why: the user asked for this park explicitly, so skip the hidden-duration + // hysteresis the automatic path uses — but keep every safety gate below it. + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs: 0, + nowMs: 0, + coldParkDelayMs: 0 + }) + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index bcbae661a6a..50a5946e770 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -14,6 +14,7 @@ import type { import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { PtyTransportRecoveryState } from './pty-transport-types' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery' export type PtyConnectionDeps = { tabId: string @@ -70,7 +71,12 @@ export type PtyConnectionDeps = { updateTabTitle: (tabId: string, title: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void - updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void markWorktreeUnread: (worktreeId: string) => void markTerminalTabUnread: (tabId: string) => void markTerminalPaneUnread: (paneKey: string) => void diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index caf324b08e2..1cf66787ac2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -19,6 +19,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' @@ -38,6 +39,20 @@ async function flushAsyncTicks(count = 6): Promise { } } +async function drainFakeTimerWork(limit = 20): Promise { + await flushAsyncTicks(20) + if (!vi.isFakeTimers()) { + return + } + for (let iteration = 0; iteration < limit && vi.getTimerCount() > 0; iteration += 1) { + await vi.runOnlyPendingTimersAsync() + await flushAsyncTicks(20) + } + vi.clearAllTimers() + await flushAsyncTicks(20) + vi.clearAllTimers() +} + async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise { let iterations = 0 while (pendingTimeouts.length > 0) { @@ -117,6 +132,7 @@ type StoreState = { title?: string launchAgent?: string shellOverride?: string + generation?: number }[] > ptyIdsByTabId?: Record @@ -216,6 +232,33 @@ type StoreState = { markTerminalTabUnread: ReturnType markTerminalPaneUnread: ReturnType markAgentCompletionPaneUnread: ReturnType + directSshPaneRetryByTabId?: Record< + string, + { + attemptId: string + authority: { + targetId: string + providerEpoch: string + connectionGeneration: number + } + tabGeneration: number + startedAt: number + } + > + directSshLivePtyBindingByTabId?: Record< + string, + { + attemptId: string + authority: { + targetId: string + providerEpoch: string + connectionGeneration: number + } + tabGeneration: number + ptyId: string + } + > + settleDirectSshPaneRetry?: ReturnType } type WindowsShiftEnterPaneState = Parameters[0] @@ -253,6 +296,7 @@ type MockTransport = { ) => unknown } disconnect: ReturnType + detach?: ReturnType sendInput: ReturnType sendInputImmediate: ReturnType sendInputAccepted?: ReturnType @@ -583,6 +627,35 @@ function createDeps(overrides: Record = {}) { } } +function createDirectSshSplitRetryCommit() { + return vi.fn( + (tabId: string, ptyId: string, _replacedPtyId?: string, directSshRetryAttemptId?: string) => { + const currentPtyIds = mockStoreState.ptyIdsByTabId?.[tabId] ?? [] + mockStoreState.ptyIdsByTabId = { + ...mockStoreState.ptyIdsByTabId, + [tabId]: currentPtyIds.includes(ptyId) ? currentPtyIds : [...currentPtyIds, ptyId] + } + const pending = mockStoreState.directSshPaneRetryByTabId?.[tabId] + if (!pending || pending.attemptId !== directSshRetryAttemptId) { + return + } + const tab = mockStoreState.tabsByWorktree['wt-1'].find((candidate) => candidate.id === tabId) + if (tab && !tab.ptyId) { + tab.ptyId = ptyId + } + mockStoreState.directSshPaneRetryByTabId = {} + mockStoreState.directSshLivePtyBindingByTabId = { + [tabId]: { + attemptId: pending.attemptId, + authority: pending.authority, + tabGeneration: pending.tabGeneration, + ptyId: tab?.ptyId ?? ptyId + } + } + } + ) +} + function setReattachPaneTitle(title: string): void { mockStoreState = { ...mockStoreState, @@ -688,12 +761,18 @@ function keyEvent(overrides: Partial): KeyboardEvent { } as KeyboardEvent } -function createDeferred(): { promise: Promise; resolve: (value: T) => void } { +function createDeferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { let resolveDeferred!: (value: T) => void - const promise = new Promise((resolve) => { + let rejectDeferred!: (reason?: unknown) => void + const promise = new Promise((resolve, reject) => { resolveDeferred = resolve + rejectDeferred = reject }) - return { promise, resolve: resolveDeferred } + return { promise, resolve: resolveDeferred, reject: rejectDeferred } } function createRect(width: number, height: number, left = 0, top = 0): DOMRect { @@ -884,6 +963,7 @@ describe('connectPanePty', () => { needsPassphrasePrompt: vi.fn().mockResolvedValue(false) }, pty: { + kill: vi.fn(), signal: vi.fn(), listSessions: vi.fn().mockResolvedValue([]), hasPty: vi.fn().mockResolvedValue(true), @@ -941,8 +1021,8 @@ describe('connectPanePty', () => { }) afterEach(async () => { - // Why: drain in-flight foreground-confirm microtasks while this test still owns the store mock, so its async fallout can't leak into (and flake) the next test. - await flushAsyncTicks() + // Drain deferred confirmation work before the next test replaces its store mock. + await drainFakeTimerWork() vi.useRealTimers() vi.restoreAllMocks() if (originalRequestAnimationFrame) { @@ -986,6 +1066,1038 @@ describe('connectPanePty', () => { logSpy.mockRestore() }, 30_000) + it.each(['rejects', 'resolves empty'] as const)( + 'settles the exact direct SSH retry when its fresh spawn %s', + async (outcome) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + if (outcome === 'rejects') { + transport.connect.mockRejectedValueOnce(new Error('spawn failed')) + } else { + transport.connect.mockResolvedValueOnce(null) + } + transportFactoryQueue.push(transport) + const settleDirectSshPaneRetry = vi.fn() + const pendingRetry = { + attemptId: 'attempt-1', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(12) + + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'failed', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + } + ) + + it('times out an exact direct SSH retry when a StrictMode-reused spawn stays pending', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const firstTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(pendingSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, remountTransport) + const pendingRetry = { + attemptId: 'attempt-timeout', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn(() => { + mockStoreState.directSshPaneRetryByTabId = {} + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + firstBinding.dispose() + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(firstTransport.connect).toHaveBeenCalledOnce() + expect(remountTransport.connect).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(30_999) + expect(settleDirectSshPaneRetry).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'timed-out', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + expect(firstTransport.disconnect).not.toHaveBeenCalled() + expect(remountTransport.disconnect).not.toHaveBeenCalled() + + pendingSpawn.resolve(null) + await flushAsyncTicks(12) + }) + + it('cancels exact retry settlement when a hung pane is intentionally disposed', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(pendingSpawn.promise) + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-disposed', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + binding.dispose() + await vi.advanceTimersByTimeAsync(31_000) + + expect(settleDirectSshPaneRetry).not.toHaveBeenCalled() + + pendingSpawn.resolve(null) + await flushAsyncTicks(12) + expect(settleDirectSshPaneRetry).not.toHaveBeenCalled() + }) + + it('joins a successful pending spawn across a same-attempt StrictMode remount', async () => { + const { connectPanePty } = await import('./pty-connection') + const pendingSpawn = createDeferred() + const firstTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(pendingSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(firstTransport, remountTransport) + const pendingRetry = { + attemptId: 'attempt-strict-mode', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const remountDeps = createDeps() + + const firstBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps() as never + ) + await flushAsyncTicks() + firstBinding.dispose() + connectPanePty(createPane(1) as never, createManager(1) as never, remountDeps as never) + await flushAsyncTicks() + + expect(firstTransport.connect).toHaveBeenCalledOnce() + expect(remountTransport.connect).not.toHaveBeenCalled() + + const spawnedPtyId = toAppSshPtyId('target-a', 'pty-strict-mode') + pendingSpawn.resolve(spawnedPtyId) + await flushAsyncTicks(12) + + expect(remountTransport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: spawnedPtyId }) + ) + expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + spawnedPtyId, + undefined, + pendingRetry.attemptId + ) + }) + + it('commits every concurrent split-pane spawn under one exact retry attempt', async () => { + const { connectPanePty } = await import('./pty-connection') + const firstSpawn = createDeferred() + const siblingSpawn = createDeferred() + const firstTransport = createMockTransport() + const siblingTransport = createMockTransport() + firstTransport.connect.mockReturnValueOnce(firstSpawn.promise) + siblingTransport.connect.mockReturnValueOnce(siblingSpawn.promise) + transportFactoryQueue.push(firstTransport, siblingTransport) + const pendingRetry = { + attemptId: 'attempt-split-spawn', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const updateTabPtyId = createDirectSshSplitRetryCommit() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + directSshLivePtyBindingByTabId: {}, + settleDirectSshPaneRetry: vi.fn() + } + const manager = createManager(2) + + connectPanePty( + createPane(1) as never, + manager as never, + createDeps({ updateTabPtyId }) as never + ) + connectPanePty( + createPane(2) as never, + manager as never, + createDeps({ updateTabPtyId }) as never + ) + await flushAsyncTicks() + + const firstPtyId = toAppSshPtyId('target-a', 'pty-first') + const siblingPtyId = toAppSshPtyId('target-a', 'pty-sibling') + const firstOnPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + const siblingOnPtySpawn = createdTransportOptions[1]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + firstOnPtySpawn?.(firstPtyId) + siblingOnPtySpawn?.(siblingPtyId) + + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + firstPtyId, + undefined, + pendingRetry.attemptId + ) + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + siblingPtyId, + undefined, + pendingRetry.attemptId + ) + expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual([firstPtyId, siblingPtyId]) + + firstSpawn.resolve(firstPtyId) + siblingSpawn.resolve(siblingPtyId) + await flushAsyncTicks(12) + }) + + it('captures retained live authority when a sibling mounts after first success', async () => { + const { connectPanePty } = await import('./pty-connection') + const delayedSpawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(delayedSpawn.promise) + transportFactoryQueue.push(transport) + const livePtyId = toAppSshPtyId('target-a', 'pty-live') + const siblingPtyId = toAppSshPtyId('target-a', 'pty-delayed-sibling') + const liveRetry = { + attemptId: 'attempt-live-sibling', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + ptyId: livePtyId + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: livePtyId, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [livePtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: { 'tab-1': liveRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const paneTransportsRef = { + current: new Map([[1, createMockTransport(livePtyId)]]) + } + const deps = createDeps({ paneTransportsRef }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(siblingPtyId) + + expect(deps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + siblingPtyId, + undefined, + liveRetry.attemptId + ) + + delayedSpawn.resolve(siblingPtyId) + await flushAsyncTicks(12) + }) + + it('rejects a delayed live-lease sibling after direct SSH authority rotates', async () => { + const { connectPanePty } = await import('./pty-connection') + const delayedSpawn = createDeferred() + const transport = createMockTransport() + const stalePtyId = toAppSshPtyId('target-a', 'pty-stale-live-sibling') + transport.getPtyId.mockReturnValue(stalePtyId) + transport.connect.mockReturnValueOnce(delayedSpawn.promise) + transportFactoryQueue.push(transport) + const livePtyId = toAppSshPtyId('target-a', 'pty-live') + const liveRetry = { + attemptId: 'attempt-live-stale', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + ptyId: livePtyId + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: livePtyId, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [livePtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: { 'tab-1': liveRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const paneTransportsRef = { + current: new Map([[1, createMockTransport(livePtyId)]]) + } + const deps = createDeps({ paneTransportsRef }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + mockStoreState.sshConnectionStates = new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + } + ] + ]) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.(stalePtyId) + await flushAsyncTicks() + + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(transport.disconnect).toHaveBeenCalledOnce() + + delayedSpawn.resolve(stalePtyId) + await flushAsyncTicks(12) + }) + + it('starts a new spawn and rejects a late callback after direct SSH authority rotates', async () => { + const { connectPanePty } = await import('./pty-connection') + const oldPendingSpawn = createDeferred() + const oldTransport = createMockTransport() + let oldTransportPtyId: string | null = null + oldTransport.getPtyId.mockImplementation(() => oldTransportPtyId) + oldTransport.disconnect.mockImplementation(() => { + oldTransportPtyId = null + }) + oldTransport.connect.mockReturnValueOnce(oldPendingSpawn.promise) + const newPendingSpawn = createDeferred() + const newTransport = createMockTransport() + newTransport.connect.mockReturnValueOnce(newPendingSpawn.promise) + transportFactoryQueue.push(oldTransport, newTransport) + const oldRetry = { + attemptId: 'attempt-old', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const newRetry = { + attemptId: 'attempt-new', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + }, + tabGeneration: 8, + startedAt: 2 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': oldRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const oldDeps = createDeps() + + const oldBinding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + oldDeps as never + ) + await flushAsyncTicks() + oldBinding.dispose() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 8 }] }, + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': newRetry } + } + const newDeps = createDeps() + + connectPanePty(createPane(1) as never, createManager(1) as never, newDeps as never) + await flushAsyncTicks() + + expect(oldTransport.connect).toHaveBeenCalledOnce() + expect(newTransport.connect).toHaveBeenCalledOnce() + + const oldPtyId = toAppSshPtyId('target-a', 'pty-old') + oldTransportPtyId = oldPtyId + const oldOnPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + oldOnPtySpawn?.(oldPtyId) + oldPendingSpawn.resolve(oldPtyId) + await flushAsyncTicks(12) + + expect(oldDeps.updateTabPtyId).not.toHaveBeenCalled() + expect(oldTransport.disconnect).toHaveBeenCalledOnce() + expect(mockStoreState.directSshPaneRetryByTabId).toEqual({ 'tab-1': newRetry }) + + const newPtyId = toAppSshPtyId('target-a', 'pty-new') + const newOnPtySpawn = createdTransportOptions[1]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + newOnPtySpawn?.(newPtyId) + newPendingSpawn.resolve(newPtyId) + await flushAsyncTicks(12) + + expect(newDeps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + newPtyId, + undefined, + newRetry.attemptId + ) + }) + + it('times out an exact direct SSH retry when reattach stays pending', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + const { connectPanePty } = await import('./pty-connection') + const pendingReattach = createDeferred() + const transport = createMockTransport() + transport.connect.mockReturnValueOnce(pendingReattach.promise).mockResolvedValueOnce(null) + transportFactoryQueue.push(transport) + const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored') + const pendingRetry = { + attemptId: 'attempt-reattach-timeout', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn(() => { + mockStoreState.directSshPaneRetryByTabId = {} + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks(12) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: restoredPtyId }) + ) + await vi.advanceTimersByTimeAsync(31_000) + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'timed-out', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + expect(transport.disconnect).not.toHaveBeenCalled() + + pendingReattach.resolve(null) + await flushAsyncTicks(12) + }) + + it('commits a successful direct SSH reattach with its exact retry lease', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored') + const transport = createMockTransport(restoredPtyId) + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-reattach-success', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const deps = createDeps() + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks(12) + + expect(deps.updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + restoredPtyId, + undefined, + pendingRetry.attemptId + ) + }) + + it('rejects expired reattach state after its direct SSH retry lease is revoked', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-reattach') + const pendingReattach = createDeferred<{ + id: string + sessionExpired: true + launchAgent: 'codex' + launchConfig: { agentCommand: string; agentArgs: string; agentEnv: Record } + }>() + const transport = createMockTransport(restoredPtyId) + transport.connect.mockReturnValueOnce(pendingReattach.promise) + transport.detach = vi.fn() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-stale-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const deps = createDeps() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks() + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ admitPtyId: expect.any(Function) }) + ) + mockStoreState.sshConnectionStates = new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-new', + connectionGeneration: 4 + } + ] + ]) + mockStoreState.directSshPaneRetryByTabId = {} + + pendingReattach.resolve({ + id: restoredPtyId, + sessionExpired: true, + launchAgent: 'codex', + launchConfig: { + agentCommand: 'codex --profile stale', + agentArgs: '--profile stale', + agentEnv: {} + } + }) + await flushAsyncTicks(12) + + expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false }) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled() + expect(mockStoreState.tabsByWorktree['wt-1']).toEqual([ + { id: 'tab-1', ptyId: restoredPtyId, generation: 7 } + ]) + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('rejects stale reattach errors after its exact retry lease is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-empty-reattach') + const pendingReattach = createDeferred() + const transport = createMockTransport(restoredPtyId) + transport.connect.mockImplementationOnce( + (options: { admitPtyId?: (ptyId: string) => boolean; callbacks?: ConnectCallbacks }) => { + expect(options.admitPtyId?.(restoredPtyId)).toBe(true) + return pendingReattach.promise + } + ) + transport.detach = vi.fn() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-stale-empty-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const deps = createDeps() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks() + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-current-reattach' } + } + + const connectOptions = transport.connect.mock.calls[0]?.[0] as { + callbacks?: ConnectCallbacks + } + connectOptions.callbacks?.onError?.('stale reattach failure') + connectOptions.callbacks?.onError?.('SSH_SESSION_EXPIRED: stale reattach') + pendingReattach.resolve() + await flushAsyncTicks(12) + + expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false }) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('rejects a reattach failure after its direct SSH retry lease is revoked', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-rejected-reattach') + const pendingReattach = createDeferred() + const transport = createMockTransport(restoredPtyId) + transport.connect.mockImplementationOnce( + (options: { admitPtyId?: (ptyId: string) => boolean }) => { + expect(options.admitPtyId?.(restoredPtyId)).toBe(true) + return pendingReattach.promise + } + ) + transport.detach = vi.fn() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-stale-rejected-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const deps = createDeps() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-old', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + ...deps, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks() + mockStoreState.directSshPaneRetryByTabId = {} + + pendingReattach.reject(new Error('stale reattach rejection')) + await flushAsyncTicks(12) + + expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false }) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.disconnect).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null) + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('commits every concurrent split-pane reattach under one exact retry attempt', async () => { + const { connectPanePty } = await import('./pty-connection') + const firstPtyId = toAppSshPtyId('target-a', 'pty-restored-first') + const siblingPtyId = toAppSshPtyId('target-a', 'pty-restored-sibling') + transportFactoryQueue.push(createMockTransport(firstPtyId), createMockTransport(siblingPtyId)) + const pendingRetry = { + attemptId: 'attempt-split-reattach', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const updateTabPtyId = createDirectSshSplitRetryCommit() + const restoredPtyIdByLeafId = { + [LEAF_1]: firstPtyId, + [LEAF_2]: siblingPtyId + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: firstPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [firstPtyId, siblingPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + directSshLivePtyBindingByTabId: {}, + settleDirectSshPaneRetry: vi.fn() + } + const manager = createManager(2) + + connectPanePty( + createPane(1) as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId, + updateTabPtyId + }) as never + ) + connectPanePty( + createPane(2) as never, + manager as never, + createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId, + updateTabPtyId + }) as never + ) + await flushAsyncTicks(12) + + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + firstPtyId, + undefined, + pendingRetry.attemptId + ) + expect(updateTabPtyId).toHaveBeenCalledWith( + 'tab-1', + siblingPtyId, + undefined, + pendingRetry.attemptId + ) + }) + // Why: hidden panes (orchestration workers, CLI terminal create) legitimately connect at 0×0 and refit when shown, so the zero-dimensions diagnostic must stay silent. it('does not surface the zero-dimensions diagnostic for a hidden pane', async () => { const { connectPanePty } = await import('./pty-connection') @@ -18795,6 +19907,188 @@ describe('connectPanePty', () => { expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() }) + it('abandons a deferred SSH retry replaced during the passphrase probe', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('conn-1', 'saved-session') + const passphraseProbe = createDeferred() + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-passphrase-probe', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + vi.mocked(window.api.ssh.needsPassphrasePrompt).mockReturnValue(passphraseProbe.promise) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(3) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-passphrase-probe-new' } + } + + passphraseProbe.resolve(false) + await flushAsyncTicks(12) + + expect(window.api.ssh.connect).not.toHaveBeenCalled() + expect(transport.connect).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() + }) + + it('drops a failed passphrase wait after its deferred SSH retry is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('conn-1', 'saved-session') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-passphrase-wait', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + vi.mocked(window.api.ssh.needsPassphrasePrompt).mockResolvedValue(true) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(3) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-passphrase-wait-new' } + } + mockStoreState.sshConnectionStates = new Map([ + [ + 'conn-1', + { + status: 'auth-failed', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]) + notifyStoreSubscribers() + await flushAsyncTicks(12) + + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + expect(window.api.ssh.connect).not.toHaveBeenCalled() + expect(transport.connect).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() + }) + + it('does not mutate deferred SSH reattach state after its connect wait lease is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('conn-1', 'saved-session') + const sshConnect = createDeferred() + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-connect-wait', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) + const paneMode2031Ref = { current: new Map([[1, true]]) } + const paneLastThemeModeRef = { current: new Map([[1, true]]) } + const deps = createDeps({ paneMode2031Ref, paneLastThemeModeRef }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { ...pendingRetry, attemptId: 'attempt-connect-wait-new' } + } + + sshConnect.resolve({ + targetId: 'conn-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + await flushAsyncTicks(12) + + expect(mockStoreState.removeDeferredSshReconnectTarget).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(window.api.pty.declarePendingPaneSerializer).not.toHaveBeenCalled() + expect(paneMode2031Ref.current.get(1)).toBe(true) + expect(paneLastThemeModeRef.current.get(1)).toBe(true) + expect(transport.connect).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + }) + it('spawns a fresh PTY when a deferred SSH session expired', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b2ae121178c..3962b06f890 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -294,9 +294,13 @@ import { registerTerminalSideEffectFactConsumer } from './terminal-side-effect-facts-handler' import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' +import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' +import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. +const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 @@ -1262,7 +1266,6 @@ export function connectPanePty( const clearRegisteredStartupLaunchConfig = (): void => { useAppStore.getState().clearAgentLaunchConfig(cacheKey) } - const pendingSpawnKey = cacheKey const neutralTerminalTitle = (): string => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( @@ -2839,8 +2842,18 @@ export function connectPanePty( deps.syncPanePtyLayoutBinding(pane.id, ptyId) notifyCodexPaneBoundForStaleSweep(ptyId) const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] - if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) { - if (options.replacePtyId) { + const directSshRetryAttemptId = + capturedDirectSshRetryPtyAccepted && directSshRetryAttempt + ? directSshRetryAttempt.attemptId + : undefined + if ( + directSshRetryAttemptId || + options.updateTabPtyId !== 'if-missing' || + !tabPtyIds.includes(ptyId) + ) { + if (directSshRetryAttemptId) { + deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId, directSshRetryAttemptId) + } else if (options.replacePtyId) { deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId) } else { deps.updateTabPtyId(deps.tabId, ptyId) @@ -2876,6 +2889,15 @@ export function connectPanePty( } const onPtySpawn = (ptyId: string): void => { + if (!claimCapturedDirectSshRetryPty(ptyId)) { + // Why: this callback proves a fresh process was created, so rejecting its obsolete lease must also retire it. + queueMicrotask(() => { + if (transport.getPtyId() === ptyId) { + transport.disconnect() + } + }) + return + } // Why: record that this exact PTY was freshly spawned (not reattached), so a // newborn shell that dies before any interaction (e.g. failing direnv on a // just-created worktree) can be kept visible rather than tearing down the @@ -2886,6 +2908,9 @@ export function connectPanePty( bindActivePanePty(ptyId, { seedInitialAgentStatus: true }) } const onPtyRebind = (ptyId: string, replacedPtyId: string): void => { + if (!canAdoptCapturedDirectSshRetryPty(ptyId)) { + return + } // Why: provider handle rotation keeps the existing pane/session generation; // replace its stale store identity without fresh-spawn exit semantics. bindActivePanePty(ptyId, { replacePtyId: replacedPtyId }) @@ -3274,6 +3299,126 @@ export function connectPanePty( !terminalOwnerUnresolved && !connectionOwnerHydrating && runtimeEnvironmentId === null ? (worktreeConnectionId ?? null) : null + type DirectSshRetryLease = Pick< + DirectSshPaneRetryAttempt, + 'attemptId' | 'authority' | 'tabGeneration' + > + const directSshRetryAttempt: DirectSshRetryLease | undefined = (() => { + const pendingAttempt = state.directSshPaneRetryByTabId?.[deps.tabId] + const liveBinding = state.directSshLivePtyBindingByTabId?.[deps.tabId] + const attempt = + pendingAttempt?.authority.targetId === connectionId && + pendingAttempt.tabGeneration === (tab?.generation ?? 0) + ? pendingAttempt + : liveBinding?.authority.targetId === connectionId && + liveBinding.tabGeneration === (tab?.generation ?? 0) + ? liveBinding + : undefined + return attempt + })() + const pendingSpawnKey = directSshRetryAttempt + ? JSON.stringify([cacheKey, directSshRetryAttempt.attemptId]) + : cacheKey + let capturedDirectSshRetryPtyAccepted = false + let directSshPaneRetrySettlementCancelled = false + const directSshPaneRetrySettlementTimers = new Set>() + const directSshPaneRetryTimedPromises = new WeakSet() + const capturedDirectSshRetryLeaseMatches = (): boolean => { + if (!directSshRetryAttempt) { + return true + } + const currentState = useAppStore.getState() + const currentConnection = currentState.sshConnectionStates.get( + directSshRetryAttempt.authority.targetId + ) + const currentTab = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + if ( + currentConnection?.providerEpoch !== directSshRetryAttempt.authority.providerEpoch || + currentConnection.connectionGeneration !== + directSshRetryAttempt.authority.connectionGeneration || + (currentTab?.generation ?? 0) !== directSshRetryAttempt.tabGeneration + ) { + return false + } + const pendingAttempt = currentState.directSshPaneRetryByTabId?.[deps.tabId] + const pendingMatches = + pendingAttempt?.attemptId === directSshRetryAttempt.attemptId && + directSshAuthoritiesEqual(pendingAttempt.authority, directSshRetryAttempt.authority) && + pendingAttempt.tabGeneration === directSshRetryAttempt.tabGeneration + const liveBinding = currentState.directSshLivePtyBindingByTabId?.[deps.tabId] + const liveBindingMatchesAttempt = + liveBinding?.attemptId === directSshRetryAttempt.attemptId && + directSshAuthoritiesEqual(liveBinding.authority, directSshRetryAttempt.authority) && + liveBinding.tabGeneration === directSshRetryAttempt.tabGeneration + return pendingMatches || liveBindingMatchesAttempt + } + const capturedDirectSshRetryStateMatches = (ptyId: string): boolean => { + if (!directSshRetryAttempt) { + return true + } + const currentConnection = useAppStore + .getState() + .sshConnectionStates.get(directSshRetryAttempt.authority.targetId) + return ( + parseAppSshPtyId(ptyId)?.connectionId === directSshRetryAttempt.authority.targetId && + currentConnection?.status === 'connected' && + capturedDirectSshRetryLeaseMatches() + ) + } + const claimCapturedDirectSshRetryPty = (ptyId: string): boolean => { + if (!capturedDirectSshRetryStateMatches(ptyId)) { + return false + } + capturedDirectSshRetryPtyAccepted = directSshRetryAttempt !== undefined + return true + } + const canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => { + const canAdopt = capturedDirectSshRetryStateMatches(ptyId) + if (canAdopt && directSshRetryAttempt) { + capturedDirectSshRetryPtyAccepted = true + } + return canAdopt + } + const settleDirectSshPaneRetryAttempt = ( + attempt: DirectSshRetryLease | undefined, + status: 'failed' | 'timed-out' + ): void => { + if (!attempt) { + return + } + useAppStore.getState().settleDirectSshPaneRetry?.({ + status, + tabId: deps.tabId, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration + }) + } + const armDirectSshPaneRetryTimeout = ( + promise: Promise, + attempt: DirectSshRetryLease | undefined + ): void => { + if (!attempt || disposed || directSshPaneRetryTimedPromises.has(promise)) { + return + } + directSshPaneRetryTimedPromises.add(promise) + const timer = setTimeout(() => { + directSshPaneRetrySettlementTimers.delete(timer) + if (directSshPaneRetrySettlementCancelled) { + return + } + settleDirectSshPaneRetryAttempt(attempt, 'timed-out') + }, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS) + directSshPaneRetrySettlementTimers.add(timer) + void promise + .finally(() => { + directSshPaneRetrySettlementTimers.delete(timer) + clearTimeout(timer) + }) + .catch(() => {}) + } const shellOverride = tab?.shellOverride // Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so // the native-Windows ConPTY heuristic misfires on a Windows client and wrongly @@ -4911,6 +5056,9 @@ export function connectPanePty( : typeof spawnedPtyId === 'string' ? spawnedPtyId : transport.getPtyId() + if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) { + return null + } if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), @@ -4997,6 +5145,18 @@ export function connectPanePty( pendingSpawnByPaneKey.delete(pendingSpawnKey) } }) + armDirectSshPaneRetryTimeout(trackedPromise, directSshRetryAttempt) + void trackedPromise.then((spawnedPtyId) => { + if (spawnedPtyId) { + return + } + queueMicrotask(() => { + if (disposed || transport.getPtyId() || pendingSpawnByPaneKey.has(pendingSpawnKey)) { + return + } + settleDirectSshPaneRetryAttempt(directSshRetryAttempt, 'failed') + }) + }) // Why: split panes in the same tab can spawn concurrently. Key by pane // as well as tab so a remount cannot attach to a sibling setup pane's PTY. pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) @@ -7319,6 +7479,16 @@ export function connectPanePty( } } + const isCapturedDirectSshReattachCurrent = (ptyId: string): boolean => + !directSshRetryAttempt || capturedDirectSshRetryStateMatches(ptyId) + const rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => { + if (!directSshRetryAttempt || (ptyId && claimCapturedDirectSshRetryPty(ptyId))) { + return false + } + transport.detach?.({ preserveExitObserver: false }) + return true + } + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, @@ -7339,6 +7509,13 @@ export function connectPanePty( return true } + const retryPtyId = + connectResult?.id ?? + (typeof result === 'string' ? result : (staleSessionId ?? transport.getPtyId())) + if (rejectObsoleteDirectSshReattach(retryPtyId)) { + // Why: an obsolete reattach must stop consuming frames without killing the durable PTY a newer lease may adopt. + return false + } const ptyId = connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId()) if (!ptyId) { @@ -7425,7 +7602,11 @@ export function connectPanePty( syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) notifyCodexPaneBoundForStaleSweep(ptyId) - deps.updateTabPtyId(deps.tabId, ptyId) + if (capturedDirectSshRetryPtyAccepted && directSshRetryAttempt) { + deps.updateTabPtyId(deps.tabId, ptyId, undefined, directSshRetryAttempt.attemptId) + } else { + deps.updateTabPtyId(deps.tabId, ptyId) + } agentCompletionCoordinator.startProcessTracking() sampleVisiblePaneForegroundAgent() @@ -7678,7 +7859,7 @@ export function connectPanePty( console.warn('[pty-connection] needsPassphrasePrompt probe failed:', err) // Why: on probe failure fall through to auto-connect rather than stranding the tab — a stuck tab is worse than a surprising prompt. } - if (disposed) { + if (disposed || !capturedDirectSshRetryLeaseMatches()) { return } if (needsPrompt) { @@ -7743,7 +7924,7 @@ export function connectPanePty( finish(currentOutcome) } }) - if (disposed) { + if (disposed || !capturedDirectSshRetryLeaseMatches()) { return } if (outcome === 'cancelled') { @@ -7758,11 +7939,11 @@ export function connectPanePty( // Why: wait for the shared SSH connection (multiple panes/tabs may need it) before PTY reattach, rather than returning early when it's in-flight. const connectResult = await waitForSshConnection(connectionId) - if (!connectResult.connected) { - reportError(`SSH connection failed: ${connectResult.error}`) + if (disposed || !capturedDirectSshRetryLeaseMatches()) { return } - if (disposed) { + if (!connectResult.connected) { + reportError(`SSH connection failed: ${connectResult.error}`) return } useAppStore.getState().removeDeferredSshReconnectTarget(connectionId) @@ -7790,6 +7971,9 @@ export function connectPanePty( expiredReattachError = true return } + if (!isCapturedDirectSshReattachCurrent(pendingSessionId)) { + return + } reportError(message) }) beginReattachLiveDataDeferral(outputCallbacks.generation) @@ -7814,6 +7998,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) @@ -7821,7 +8006,7 @@ export function connectPanePty( .finally(() => { transportConnectInFlightSince = null }) - void Promise.resolve(reattachPromise) + const trackedReattachPromise = Promise.resolve(reattachPromise) .then(async (result) => { if (outputCallbacks.generation !== transportStreamGeneration) { finishReattachLiveDataDeferral(false, outputCallbacks.generation) @@ -7849,6 +8034,9 @@ export function connectPanePty( if (disposed) { return } + if (rejectObsoleteDirectSshReattach(pendingSessionId)) { + return + } deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) startFreshColdRestoreAgentResume(coldRestoreStartup, { @@ -7892,6 +8080,9 @@ export function connectPanePty( if (disposed || outputCallbacks.generation !== transportStreamGeneration) { return } + if (rejectObsoleteDirectSshReattach(pendingSessionId)) { + return + } if (isSshSessionExpiredError(err)) { deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) @@ -7904,6 +8095,7 @@ export function connectPanePty( forceBlankRestoredViewport: true }) }) + armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) } else { startFreshColdRestoreAgentResume() } @@ -8013,6 +8205,9 @@ export function connectPanePty( expiredReattachError = true return } + if (!isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) { + return + } reportError(message) }) beginReattachLiveDataDeferral(outputCallbacks.generation) @@ -8035,6 +8230,7 @@ export function connectPanePty( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), callbacks: outputCallbacks.callbacks }) @@ -8043,7 +8239,7 @@ export function connectPanePty( .finally(() => { transportConnectInFlightSince = null }) - void Promise.resolve(reattachPromise) + const trackedReattachPromise = Promise.resolve(reattachPromise) .then(async (result) => { if (outputCallbacks.generation !== transportStreamGeneration) { finishReattachLiveDataDeferral(false, outputCallbacks.generation) @@ -8062,6 +8258,9 @@ export function connectPanePty( if (disposed) { return } + if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { + return + } deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) startFreshColdRestoreAgentResume(coldRestoreStartup, { @@ -8105,6 +8304,9 @@ export function connectPanePty( if (outputCallbacks.generation !== transportStreamGeneration) { return } + if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, @@ -8126,6 +8328,7 @@ export function connectPanePty( forceBlankRestoredViewport: true }) }) + armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) } else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) { // Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab. // eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt. @@ -8162,6 +8365,7 @@ export function connectPanePty( const pendingSpawn = pendingSpawnByPaneKey.get(pendingSpawnKey) if (pendingSpawn) { recordPtyConnectDiagnostic(`pane=${pane.id} -> PENDING SPAWN`) + armDirectSshPaneRetryTimeout(pendingSpawn, directSshRetryAttempt) void pendingSpawn .then((spawnedPtyId) => { if (disposed) { @@ -8185,6 +8389,9 @@ export function connectPanePty( } return } + if (!canAdoptCapturedDirectSshRetryPty(spawnedPtyId)) { + return + } clearPaneMode2031State() clearHiddenOutputRestoreState() const outputCallbacks = captureTransportOutputCallbacks(reportError) @@ -8366,6 +8573,11 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + directSshPaneRetrySettlementCancelled = true + for (const timer of directSshPaneRetrySettlementTimers) { + clearTimeout(timer) + } + directSshPaneRetrySettlementTimers.clear() // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. for (const chunk of deferredReattachLiveData ?? []) { chunk.ackCredit?.() diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 804655edbdf..ef7d9974464 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -119,6 +119,8 @@ export type PtyTransport = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + /** Reject a stale restored identity before this transport can publish global PTY handlers. */ + admitPtyId?: (ptyId: string) => boolean callbacks: PtyCallbacks }) => void | Promise attach: (options: { @@ -170,7 +172,7 @@ export type PtyTransport = { resetCrossChunkParserState?: () => void serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void - detach?: () => void + detach?: (options?: { preserveExitObserver?: boolean }) => void destroy?: () => void | Promise } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index a0bc22e229c..3a71a811bc5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -277,6 +277,109 @@ describe('createIpcPtyTransport', () => { expect(exitSeenByNewPane).toHaveBeenCalledWith(0) }) + it('rejects a stale reattach before it can replace newer PTY handlers', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + let resolveStale!: (value: { id: string; isReattach: boolean }) => void + spawn.mockReturnValueOnce( + new Promise((resolve) => { + resolveStale = resolve + }) + ) + const staleData = vi.fn() + const staleExit = vi.fn() + const stalePane = createIpcPtyTransport({}) + const staleConnect = stalePane.connect({ + url: '', + sessionId: 'pty-1', + admitPtyId: () => false, + callbacks: { onData: staleData, onExit: staleExit } + }) + const currentData = vi.fn() + const currentExit = vi.fn() + const currentPane = createIpcPtyTransport({}) + currentPane.attach({ + existingPtyId: 'pty-1', + callbacks: { onData: currentData, onExit: currentExit } + }) + + resolveStale({ id: 'pty-1', isReattach: true }) + await staleConnect + onData?.({ id: 'pty-1', data: 'current output' }) + onExit?.({ id: 'pty-1', code: 0 }) + + expect(currentData).toHaveBeenCalledWith('current output') + expect(currentExit).toHaveBeenCalledWith(0) + expect(staleData).not.toHaveBeenCalled() + expect(staleExit).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + }) + + it('retires a rejected fresh fallback before it can publish PTY handlers', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + const onPtySpawn = vi.fn() + const onDataCallback = vi.fn() + const onExitCallback = vi.fn() + spawn.mockResolvedValueOnce({ id: 'pty-fresh-fallback', sessionExpired: true }) + const transport = createIpcPtyTransport({ onPtySpawn }) + + const result = await transport.connect({ + url: '', + sessionId: 'pty-missing', + admitPtyId: () => false, + callbacks: { onData: onDataCallback, onExit: onExitCallback } + }) + onData?.({ id: 'pty-fresh-fallback', data: 'orphaned output' }) + onExit?.({ id: 'pty-fresh-fallback', code: 0 }) + + expect(result).toEqual({ id: 'pty-fresh-fallback', sessionExpired: true }) + expect(kill).toHaveBeenCalledExactlyOnceWith('pty-fresh-fallback') + expect(onPtySpawn).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBeNull() + expect(transport.isConnected()).toBe(false) + }) + + it('surfaces rejected fresh fallback retirement without publishing PTY handlers', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + const onPtySpawn = vi.fn() + const onDataCallback = vi.fn() + const onExitCallback = vi.fn() + const onErrorCallback = vi.fn() + const retirementError = new Error('provider shutdown refused') + spawn.mockResolvedValueOnce({ id: 'pty-fresh-fallback', sessionExpired: true }) + kill.mockRejectedValueOnce(retirementError) + const transport = createIpcPtyTransport({ onPtySpawn }) + + await expect( + transport.connect({ + url: '', + sessionId: 'pty-missing', + admitPtyId: () => false, + callbacks: { + onData: onDataCallback, + onExit: onExitCallback, + onError: onErrorCallback + } + }) + ).resolves.toBeUndefined() + onData?.({ id: 'pty-fresh-fallback', data: 'orphaned output' }) + onExit?.({ id: 'pty-fresh-fallback', code: 0 }) + + expect(kill).toHaveBeenCalledExactlyOnceWith('pty-fresh-fallback') + expect(onPtySpawn).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(onErrorCallback).toHaveBeenCalledExactlyOnceWith(retirementError.message) + expect(transport.getPtyId()).toBeNull() + expect(transport.isConnected()).toBe(false) + }) + it('buffers data across a normal detach-then-attach gap and drains it to the next pane', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const receivedByNewPane = vi.fn() @@ -463,6 +566,38 @@ describe('createIpcPtyTransport', () => { expect(transport.isConnected()).toBe(false) }) + it('rejects a buffered dead-session exit before publishing its final frame', async () => { + const { bufferPreHandlerPtyData, bufferPreHandlerPtyExit, clearPreHandlerPtyState } = + await import('./pty-pre-handler-buffer') + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const onDataCallback = vi.fn() + const onExitCallback = vi.fn() + const onDisconnect = vi.fn() + const onPtyExit = vi.fn() + const sessionId = 'stale-dead-parked-session' + bufferPreHandlerPtyData(sessionId, 'stale final output') + bufferPreHandlerPtyExit(sessionId, 17) + + const transport = createIpcPtyTransport({ onPtyExit }) + const result = await transport.connect({ + url: '', + sessionId, + admitPtyId: () => false, + callbacks: { onData: onDataCallback, onExit: onExitCallback, onDisconnect } + }) + + expect(result).toEqual({ id: sessionId }) + expect(spawn).not.toHaveBeenCalled() + expect(onDataCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(onDisconnect).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(window.api.pty.kill).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + clearPreHandlerPtyState(sessionId) + }) + it('returns startup cwd fallback metadata to the connection layer', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -1527,8 +1662,10 @@ describe('createIpcPtyTransport', () => { it('does not kill a pre-existing session when a reattach resolves after destroy', async () => { const { createIpcPtyTransport } = await import('./pty-transport') - const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null } - const spawnPromise = new Promise<{ id: string }>((resolve) => { + const spawnControls: { + resolve: ((value: { id: string; isReattach: true }) => void) | null + } = { resolve: null } + const spawnPromise = new Promise<{ id: string; isReattach: true }>((resolve) => { spawnControls.resolve = resolve }) const spawnMock = vi.fn().mockReturnValue(spawnPromise) @@ -1563,12 +1700,41 @@ describe('createIpcPtyTransport', () => { if (!spawnControls.resolve) { throw new Error('Expected spawn resolver to be captured') } - spawnControls.resolve({ id: 'pty-preexisting' }) + spawnControls.resolve({ id: 'pty-preexisting', isReattach: true }) await connectPromise expect(killMock).not.toHaveBeenCalledWith('pty-preexisting') }) + it('kills a fresh session fallback that resolves after the transport was destroyed', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnControls: { + resolve: ((value: { id: string; sessionExpired: true }) => void) | null + } = { resolve: null } + const spawnPromise = new Promise<{ id: string; sessionExpired: true }>((resolve) => { + spawnControls.resolve = resolve + }) + const spawn = window.api.pty.spawn as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + spawn.mockReturnValueOnce(spawnPromise) + const transport = createIpcPtyTransport({}) + const connectPromise = transport.connect({ + url: '', + sessionId: 'pty-missing', + callbacks: {} + }) + + transport.destroy?.() + if (!spawnControls.resolve) { + throw new Error('Expected spawn resolver to be captured') + } + spawnControls.resolve({ id: 'pty-fresh-fallback', sessionExpired: true }) + await connectPromise + + expect(kill).toHaveBeenCalledExactlyOnceWith('pty-fresh-fallback') + expect(transport.getPtyId()).toBeNull() + }) + it('kills a PTY that finishes spawning after the transport was destroyed', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null } @@ -2025,6 +2191,42 @@ describe('createIpcPtyTransport', () => { expect(onPtyExit).toHaveBeenCalledWith('pty-detached') expect(transport.getPtyId()).toBeNull() }) + + it('drops the exit observer when abandoning an obsolete reattach without killing it', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onPtyExit = vi.fn() + const kill = window.api.pty.kill as unknown as ReturnType + const transport = createIpcPtyTransport({ onPtyExit }) + const onDataCallback = vi.fn() + const onReplayData = vi.fn() + const onWriteUnavailableCallback = vi.fn() + const onExitCallback = vi.fn() + const onDisconnect = vi.fn() + + transport.attach({ + existingPtyId: 'pty-obsolete', + callbacks: { + onData: onDataCallback, + onReplayData, + onWriteUnavailable: onWriteUnavailableCallback, + onExit: onExitCallback, + onDisconnect + } + }) + transport.detach?.({ preserveExitObserver: false }) + onData?.({ id: 'pty-obsolete', data: 'stale data' }) + onReplay?.({ id: 'pty-obsolete', data: 'stale replay' }) + onWriteUnavailable?.({ id: 'pty-obsolete' }) + onExit?.({ id: 'pty-obsolete', code: 0 }) + + expect(onDataCallback).not.toHaveBeenCalled() + expect(onReplayData).not.toHaveBeenCalled() + expect(onWriteUnavailableCallback).not.toHaveBeenCalled() + expect(onExitCallback).not.toHaveBeenCalled() + expect(onDisconnect).not.toHaveBeenCalled() + expect(onPtyExit).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + }) }) describe('createRemoteRuntimePtyTransport', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 1e3054f7050..7df7fc18ab5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -696,6 +696,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } if (options.sessionId && hasPreHandlerPtyExit(options.sessionId)) { + if (options.admitPtyId && !options.admitPtyId(options.sessionId)) { + return { id: options.sessionId } satisfies PtyConnectResult + } // Why: deliver the exited parked session's buffered final frame/exit before spawn, so the dead incarnation can't orphan a fresh shell reusing its id. ptyId = options.sessionId connected = true @@ -761,15 +764,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const resultLaunchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined + const retireFreshSpawn = async (): Promise => { + if (!spawnResult.isReattach && !spawnResult.coldRestore) { + await window.api.pty.kill(spawnResult.id) + } + } // Why: on destroy mid-connect, kill only a fresh spawn — killing a reattached session (owned by the tab lifecycle) loses a live shell. if (destroyed) { - if (!options.sessionId) { - window.api.pty.kill(spawnResult.id) - } + await retireFreshSpawn() return } + if (options.admitPtyId && !options.admitPtyId(spawnResult.id)) { + // Why: a rejected session-expired fallback has no owner to retire its newly created process. + await retireFreshSpawn() + return spawnResult + } + ptyId = spawnResult.id connected = true @@ -933,12 +945,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, - detach() { + detach(options) { clearAccumulatedState() inputWriteQueue.clear() if (ptyId) { // Why: on remount keep the exit observer alive so a shell dying in the gap still clears stale tab/leaf bindings before reattach. - unregisterPtyDataAndStatusHandlers(ptyId) + if (options?.preserveExitObserver === false) { + unregisterPtyHandlers(ptyId) + } else { + unregisterPtyDataAndStatusHandlers(ptyId) + } } connected = false ptyId = null diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index cb1c698fc15..5c8026b74d0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -7,7 +7,6 @@ import { TERMINAL_TAB_HOT_RETAIN_MS, TERMINAL_WORKTREE_HOT_RETAIN_MS, TERMINAL_WORKTREE_PARK_DELAY_MS, - canManuallyParkTerminalWorktreeRenderers, canParkTerminalTabRenderer, canParkTerminalWorktreeRenderers, getTerminalTabColdParkRecheckDelayMs, @@ -182,36 +181,6 @@ describe('canParkTerminalTabRenderer', () => { }) }) -describe('canManuallyParkTerminalWorktreeRenderers', () => { - const base = { - worktreeId: 'repo::/worktree', - terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], - pendingStartupByTabId: {}, - parkingEnabled: true - } - - it('bypasses visibility timing for snapshot-backed local terminals', () => { - expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true) - }) - - it('preserves safety gates for empty, pending, remote, and disabled terminals', () => { - expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false) - expect( - canManuallyParkTerminalWorktreeRenderers({ - ...base, - pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] } - }) - ).toBe(false) - expect( - canManuallyParkTerminalWorktreeRenderers({ - ...base, - terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }] - }) - ).toBe(false) - expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) - }) -}) - describe('selectColdParkedTerminalWorktrees', () => { const nowMs = 500_000 diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index 4701ff727a3..f4b434c948b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -107,26 +107,6 @@ export function canParkTerminalWorktreeRenderers(args: { }) } -export function canManuallyParkTerminalWorktreeRenderers(args: { - worktreeId: string - terminalTabs: readonly ColdParkableTerminalTab[] - pendingStartupByTabId: Readonly> - parkingEnabled: boolean -}): boolean { - return ( - args.terminalTabs.length > 0 && - canParkTerminalWorktreeRenderers({ - ...args, - isVisible: false, - shouldMeasureHiddenWorktree: false, - hasActivityTerminalPortal: false, - hiddenSinceMs: 0, - nowMs: 0, - coldParkDelayMs: 0 - }) - ) -} - export function canParkTerminalTabRenderer(args: { worktreeId: string terminalTab: TerminalTabColdParkCandidate diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 3d5108da618..8cfbc886925 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -416,4 +416,39 @@ describe('detachTerminalPaneToTab', () => { ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } }) }) + + it('keeps a detached null-PTY leaf eligible to finish its pending activation', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBeNull() + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, 'powershell.exe', { + activate: true, + pendingActivationSpawn: true, + recordInteraction: true + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index 7cbc9c095f1..f6cac5c5aca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -285,6 +285,7 @@ export function detachTerminalPaneToTab(args: { const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, sourceShellOverride, { activate: true, initialPtyId: ptyId ?? undefined, + ...(!ptyId ? { pendingActivationSpawn: true } : {}), recordInteraction: true }) const afterCreateStore = args.getStore() diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index ba963681a49..bd044d1578f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -764,6 +764,36 @@ describe('fallbackParkedPaneCandidates', () => { ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) }) + // Why: a tab that never mounted a pane persists a rootless layout. Walking + // only `root` yielded zero candidates, so watcher coverage refused it and a + // manual park could never succeed for a workspace the user had not visited. + it('resolves the single leaf of a rootless layout', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('returns nothing for a rootless layout with no resolvable leaf', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: null, expandedLeafId: null } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([]) + }) + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { expect( fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index f7644935509..4328f5db980 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -9,7 +9,10 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '@/store' import { closeTerminalTab } from '../terminal/terminal-tab-actions' -import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { + collectLeafIdsInOrder, + resolveRootlessTerminalLayoutLeafId +} from './terminal-layout-leaf-ids' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { subscribeToPtyExit } from './pty-dispatcher' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' @@ -54,7 +57,14 @@ export function fallbackParkedPaneCandidates( state: ParkedPaneFallbackState ): ParkedTerminalPaneCapture[] { const layout = state.terminalLayoutsByTabId[tab.id] - const leafIds = collectLeafIdsInOrder(layout?.root) + // Why: a tab that never mounted a pane persists a rootless layout, so there is + // no root to walk. replayTerminalLayout resolves the single leaf for that same + // shape — match it here, or such a tab is permanently uncoverable and can + // never park. + const rootLeafIds = collectLeafIdsInOrder(layout?.root) + const rootlessLeafId = layout ? resolveRootlessTerminalLayoutLeafId(layout) : null + const leafIds = + rootLeafIds.length > 0 ? rootLeafIds : rootlessLeafId !== null ? [rootlessLeafId] : [] if (leafIds.length === 0) { return [] } diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index b4029037a68..57cc3eb4de6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -1,4 +1,10 @@ -import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' +import { + keybindingMatchesAction, + type KeybindingInput, + type KeybindingMatchOptions, + type KeybindingOverrides, + type TerminalShortcutPolicy +} from '../../../../shared/keybindings' import type { WindowsShiftEnterEncoding } from './terminal-windows-shift-enter' export type TerminalShortcutEvent = { @@ -13,6 +19,23 @@ export type TerminalShortcutEvent = { export type MacOptionAsAlt = 'true' | 'false' | 'left' | 'right' +// Shared close-chord predicate: the terminal pane (L3) and the floating panel's focused-terminal +// branch (L2) both treat terminal.closePane OR a terminal-scope tab.close as "close the active +// pane," so the two layers can't diverge. Callers pass the options each binding needs — +// terminal.closePane is context-free; tab.close is scoped to the terminal surface. +export function isTerminalPaneCloseChord( + event: KeybindingInput, + platform: NodeJS.Platform, + keybindings: KeybindingOverrides | undefined, + closePaneOptions?: KeybindingMatchOptions, + tabCloseOptions?: KeybindingMatchOptions +): boolean { + return ( + keybindingMatchesAction('terminal.closePane', event, platform, keybindings, closePaneOptions) || + keybindingMatchesAction('tab.close', event, platform, keybindings, tabCloseOptions) + ) +} + // Why: macOS composition rewrites event.key for punctuation, so map event.code to the unmodified char for Esc+ sequences. const PUNCTUATION_CODE_MAP: Record = { Period: '.', @@ -82,7 +105,9 @@ export function resolveTerminalShortcutAction( // Why: lazy so agent-state lookup for the pane's Windows encoding runs only on Shift+Enter, not every keystroke. getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding, // Why: keybindings follow the client OS, but byte protocols follow the PTY host — they differ for macOS clients on Windows runtimes. - isWindowsTerminalHost: () => boolean = () => isWindows + isWindowsTerminalHost: () => boolean = () => isWindows, + // Why: gates the tab.close pane-close alias — under terminal-first a remapped tab.close yields to the shell (terminal.closePane, scope terminal, still closes). + terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first' ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -128,7 +153,14 @@ export function resolveTerminalShortcutAction( return { type: 'clearPaneTitle' } } - if (keybindingMatchesAction('terminal.closePane', event, platform, keybindings)) { + // Why: recognize the active tab.close binding as a pane-close alias too, so a user who remaps + // tab.close alone still closes the focused split pane (never the whole tab); L2 always defers to us. + if ( + isTerminalPaneCloseChord(event, platform, keybindings, undefined, { + context: 'terminal', + terminalShortcutPolicy + }) + ) { return { type: 'closeActivePane' } } diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts index 2d1ed09afce..d0096935e67 100644 --- a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts @@ -2,13 +2,14 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { MANUAL_TERMINAL_WORKTREE_PARK_EVENT, takeAllPendingManualTerminalWorktreeParks, takePendingManualTerminalWorktreePark, type ManualTerminalWorktreeParkDetail } from '@/lib/manual-terminal-worktree-parking' -import { canManuallyParkTerminalWorktreeRenderers } from './terminal-hidden-view-parking' +import { canManuallyParkTerminalWorktreeRenderers } from './manual-terminal-worktree-park-eligibility' import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' function addWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { @@ -53,7 +54,8 @@ export function useManualTerminalWorktreeParking(args: { worktreeId, terminalTabs, pendingStartupByTabId: state.pendingStartupByTabId, - parkingEnabled: state.settings?.terminalHiddenViewParking !== false + parkingEnabled: state.settings?.terminalHiddenViewParking !== false, + hasLivePty: (tabId) => tabHasLivePty(state.ptyIdsByTabId, tabId) }) && terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab)) if (!canPark) { toast.warning( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 48ced74b061..66e91ee7fdb 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -25,6 +25,7 @@ import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows- import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent' import { useAppStore } from '@/store' +import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery' import { createFilePathLinkProvider, getTerminalFileOpenHint, @@ -276,7 +277,12 @@ type UseTerminalPaneLifecycleDeps = { updateTabTitle: (tabId: string, title: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void - updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void markWorktreeUnread: (worktreeId: string) => void markTerminalTabUnread: (tabId: string) => void markTerminalPaneUnread: (paneKey: string) => void diff --git a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx index 3c13aea87e1..b10ba51caaf 100644 --- a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx +++ b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { useRef } from 'react' -import { act } from 'react' +import { useRef, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss' diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index ce77f8d1eba..59bce894d19 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -2,7 +2,8 @@ import { useRef, useState } from 'react' import type { Repo, TerminalQuickCommand, - TerminalQuickCommandScope + TerminalQuickCommandScope, + TuiAgent } from '../../../../shared/types' import { getTerminalQuickCommandAction, @@ -21,7 +22,6 @@ import { import { Label } from '@/components/ui/label' import { getAgentCatalog } from '@/lib/agent-catalog' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import type { TuiAgent } from '../../../../shared/types' import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle' import { TerminalQuickCommandAdvancedSection } from './TerminalQuickCommandAdvancedSection' import { TerminalQuickCommandContentSection } from './TerminalQuickCommandContentSection' diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e07b5d6a32b..390ebfe36c5 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -79,6 +79,7 @@ declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface ImportMetaEnv { + readonly VITE_DIRECT_SSH_RECONNECT_COORDINATOR?: string readonly VITE_EXPOSE_STORE?: boolean } diff --git a/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx b/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx new file mode 100644 index 00000000000..19109c2d377 --- /dev/null +++ b/src/renderer/src/hooks/MacosTccPromptNoticeHost.tsx @@ -0,0 +1,6 @@ +import { useMacosTccPromptNotice } from './useMacosTccPromptNotice' + +export function MacosTccPromptNoticeHost(): null { + useMacosTccPromptNotice() + return null +} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts new file mode 100644 index 00000000000..7bb5e6cfd53 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts @@ -0,0 +1,20 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import type { AppState } from '../store/types' + +export function directSshHostHydrationScope( + state: AppState, + authority: DirectSshAuthority, + catalogRevision: number +) { + return resolveDirectSshTargetScope({ + targetId: authority.targetId, + catalogRevision, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }) +} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts new file mode 100644 index 00000000000..7e1e776674a --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts @@ -0,0 +1,17 @@ +import type { DirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import type { DirectSshPreparationInputTelemetry } from './direct-ssh-reconnect-coordinator' + +export function directSshHostHydrationTelemetry( + scope: DirectSshTargetScope, + catalogOutcome: DirectSshPreparationInputTelemetry['catalogOutcome'], + catalogDurationMs: number +): DirectSshPreparationInputTelemetry { + return { + catalogOutcome, + catalogDurationMs, + gitWorktreeCount: scope.gitWorktreeIds.size, + folderWorkspaceCount: Math.max(0, scope.terminalWorkspaceKeys.size - scope.gitWorktreeIds.size), + ambiguousOwnerCount: scope.ambiguousOwnerCount, + contradictoryOwnerCount: scope.contradictoryOwnerCount + } +} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts new file mode 100644 index 00000000000..71d29e256e6 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts @@ -0,0 +1,390 @@ +import { createStore } from 'zustand/vanilla' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' +import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import type { Repo } from '../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '../store/types' +import { createDirectSshHostHydration } from './direct-ssh-host-hydration' + +function authority(targetId = 'target-a', epoch = 'epoch-a'): DirectSshAuthority { + return { + targetId, + providerEpoch: epoch as SshProviderEpoch, + connectionGeneration: 1 + } +} + +function repo(id: string, targetId: string | null): Repo { + return { + id, + path: targetId ? `/${targetId}/${id}` : `/local/${id}`, + projectGroupId: null, + connectionId: targetId, + executionHostId: targetId ? (`ssh:${targetId}` as const) : ('local' as const) + } as Repo +} + +function state(overrides: Record = {}): AppState { + return { + repos: [], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + ...overrides + } as unknown as AppState +} + +function hostSnapshot( + owner: DirectSshAuthority, + repos: ReturnType[] +): HostRepoCatalogSnapshot { + return { + authoritative: true as const, + authority: { + kind: 'direct-ssh' as const, + executionHostId: `ssh:${owner.targetId}` as const, + ...owner + }, + repos + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createDirectSshHostHydration', () => { + it('replaces only the exact SSH host catalog when repo IDs collide', async () => { + const owner = authority() + const store = createStore(() => + state({ + repos: [repo('shared', null), repo('shared', 'target-a'), repo('shared', 'target-b')] + }) + ) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => hostSnapshot(owner, [repo('shared', 'target-a')])), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + const input = await hydration.capturePreparationInput(owner, 'reconnect') + + expect(input?.repoRefs).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) + expect(store.getState().repos).toEqual([ + repo('shared', null), + repo('shared', 'target-b'), + repo('shared', 'target-a') + ]) + }) + + it('rejects a mismatched host response without publishing', async () => { + const owner = authority() + const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) + const before = store.getState().repos + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => + hostSnapshot(authority('target-b', 'epoch-b'), [repo('wrong', 'target-b')]) + ), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + await expect(hydration.capturePreparationInput(owner, 'wake-refresh')).resolves.toBeNull() + expect(store.getState().repos).toBe(before) + expect(publications).toBe(0) + }) + + it('settles a non-cooperative catalog read at five seconds and clears its timer', async () => { + vi.useFakeTimers() + const owner = authority() + const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) + let resolveLate!: (value: HostRepoCatalogSnapshot) => void + const lateSnapshot = new Promise((resolve) => { + resolveLate = resolve + }) + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(() => lateSnapshot), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + const pending = hydration.capturePreparationInput(owner, 'wake-refresh') + let settled = false + void pending.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toMatchObject({ + catalogRevision: 0, + repoRefs: [{ repoId: 'cached', executionHostId: 'ssh:target-a' }] + }) + expect(vi.getTimerCount()).toBe(0) + resolveLate(hostSnapshot(owner, [repo('late', 'target-a')])) + await Promise.resolve() + await Promise.resolve() + expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) + expect(publications).toBe(0) + }) + + it('discards a catalog reply after authority changes with zero mutation', async () => { + const owner = authority() + const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) + let current = true + let resolve!: (value: ReturnType) => void + const pendingSnapshot = new Promise((settle) => { + resolve = settle + }) + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: () => pendingSnapshot, + listLineage: vi.fn(), + isCurrentAuthority: () => current + }) + + const pending = hydration.capturePreparationInput(owner, 'reconnect') + current = false + resolve(hostSnapshot(owner, [repo('new', 'target-a')])) + + await expect(pending).resolves.toBeNull() + expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) + expect(publications).toBe(0) + }) + + it('settles pending host reads and clears their timers on stop', async () => { + vi.useFakeTimers() + const owner = authority() + const store = createStore(() => state()) + const hydration = createDirectSshHostHydration({ + store, + listRepos: () => new Promise(() => {}), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + const pending = hydration.capturePreparationInput(owner, 'wake-refresh') + await Promise.resolve() + expect(vi.getTimerCount()).toBe(1) + hydration.stop() + + await expect(pending).resolves.toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('replaces only exact-host Git and folder lineage across both key namespaces', async () => { + const owner = authority() + const store = createStore(() => + state({ + repos: [ + repo('a', 'target-a'), + repo('b', 'target-b'), + repo('ambiguous', 'target-a'), + repo('ambiguous', 'target-a'), + repo('contradictory', 'target-a') + ], + worktreesByRepo: { + a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], + b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }], + ambiguous: [ + { + id: 'ambiguous::/work', + repoId: 'ambiguous', + hostId: 'ssh:target-a' + } + ], + contradictory: [ + { + id: 'contradictory::/work', + repoId: 'contradictory', + hostId: 'ssh:target-b' + } + ] + }, + projectGroups: [ + { + id: 'group-a', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + }, + { + id: 'group-b', + parentGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + folderWorkspaces: [ + { + id: 'folder-a', + projectGroupId: 'group-a', + folderPath: '/target-a/folder', + connectionId: 'target-a' + }, + { + id: 'folder-b', + projectGroupId: 'group-b', + folderPath: '/target-b/folder', + connectionId: 'target-b' + } + ], + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'stale-a' }, + 'b::/work': { parentWorktreeId: 'keep-b' }, + 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, + 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'stale-a-workspace' }, + [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, + [worktreeWorkspaceKey('ambiguous::/work')]: { + parentWorkspaceKey: 'keep-ambiguous-workspace' + }, + [worktreeWorkspaceKey('contradictory::/work')]: { + parentWorkspaceKey: 'keep-contradictory-workspace' + }, + [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'stale-folder' }, + [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' } + } + }) + ) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(), + listLineage: vi.fn( + async () => + ({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...owner + }, + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'fresh-a' }, + 'b::/work': { parentWorktreeId: 'foreign-overwrite' }, + 'ambiguous::/work': { parentWorktreeId: 'ambiguous-overwrite' }, + 'contradictory::/work': { parentWorktreeId: 'contradictory-overwrite' } + }, + workspaceLineageByChildKey: { + 'a::/work': { parentWorkspaceKey: 'raw-terminal-key-must-not-enter-lineage' }, + [worktreeWorkspaceKey('a::/work')]: { + parentWorkspaceKey: 'fresh-a-workspace' + }, + [worktreeWorkspaceKey('b::/work')]: { + parentWorkspaceKey: 'foreign-workspace-overwrite' + }, + [worktreeWorkspaceKey('ambiguous::/work')]: { + parentWorkspaceKey: 'ambiguous-workspace-overwrite' + }, + [worktreeWorkspaceKey('contradictory::/work')]: { + parentWorkspaceKey: 'contradictory-workspace-overwrite' + }, + [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' }, + [folderWorkspaceKey('folder-b')]: { + parentWorkspaceKey: 'foreign-folder-overwrite' + } + } + }) as unknown as HostLineageSnapshot + ), + isCurrentAuthority: () => true + }) + + await expect( + hydration.readHostScopedLineage({ + ...owner, + catalogRevision: 0, + repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], + authorityRequirement: 'required', + reason: 'reconnect' + }) + ).resolves.toBe('complete') + expect(store.getState().worktreeLineageById).toEqual({ + 'a::/work': { parentWorktreeId: 'fresh-a' }, + 'b::/work': { parentWorktreeId: 'keep-b' }, + 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, + 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } + }) + expect(store.getState().workspaceLineageByChildKey).toEqual({ + [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, + [worktreeWorkspaceKey('ambiguous::/work')]: { + parentWorkspaceKey: 'keep-ambiguous-workspace' + }, + [worktreeWorkspaceKey('contradictory::/work')]: { + parentWorkspaceKey: 'keep-contradictory-workspace' + }, + [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' }, + [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'fresh-a-workspace' }, + [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' } + }) + }) + + it('rejects lineage captured before a newer same-authority catalog revision', async () => { + const owner = authority() + const store = createStore(() => + state({ + repos: [repo('a', 'target-a')], + worktreesByRepo: { + a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }] + }, + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'keep-current' } + } + }) + ) + let resolveLineage!: (value: HostLineageSnapshot) => void + const pendingLineage = new Promise((resolve) => { + resolveLineage = resolve + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => hostSnapshot(owner, [repo('a', 'target-a')])), + listLineage: () => pendingLineage, + isCurrentAuthority: () => true + }) + const firstInput = await hydration.capturePreparationInput(owner, 'reconnect') + if (!firstInput) { + throw new Error('Expected first preparation input') + } + const pending = hydration.readHostScopedLineage(firstInput) + await hydration.capturePreparationInput(owner, 'wake-refresh') + const beforeLateLineage = store.getState().worktreeLineageById + resolveLineage({ + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...owner + }, + worktreeLineageById: { + 'a::/work': { parentWorktreeId: 'stale-overwrite' } + }, + workspaceLineageByChildKey: {} + } as unknown as HostLineageSnapshot) + + await expect(pending).resolves.toBe('stale') + expect(store.getState().worktreeLineageById).toBe(beforeLateLineage) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts new file mode 100644 index 00000000000..d3a5a2bc366 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.ts @@ -0,0 +1,311 @@ +import type { StoreApi } from 'zustand' +import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' +import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { isWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '../store/types' +import type { + DirectSshLineageOutcome, + DirectSshPreparationInput, + DirectSshPreparationReason +} from './direct-ssh-reconnect-coordinator' +import { directSshHostHydrationTelemetry } from './direct-ssh-host-hydration-telemetry' +import { directSshHostHydrationScope } from './direct-ssh-host-hydration-scope' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' + +export const DIRECT_SSH_HOST_READ_TIMEOUT_MS = 5_000 + +type HostReadTimer = unknown + +export type DirectSshHostHydrationDeps = { + store: Pick, 'getState' | 'setState'> + listRepos: (authority: DirectSshAuthority) => Promise + listLineage: (authority: DirectSshAuthority) => Promise + isCurrentAuthority: (authority: DirectSshAuthority) => boolean + setTimer?: (callback: () => void, delayMs: number) => HostReadTimer + clearTimer?: (timer: HostReadTimer) => void +} + +export type DirectSshHostHydration = { + capturePreparationInput: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + snapshotRevision?: number + ) => Promise + readHostScopedLineage: (input: DirectSshPreparationInput) => Promise + isPreparationTokenCurrent: (token: { + authority: DirectSshAuthority + catalogRevision: number + repoFingerprint: string + }) => boolean + stop: () => void +} + +type BoundedResult = { status: 'complete'; value: T } | { status: 'timed-out' | 'unavailable' } + +function isAuthoritativeHost( + authority: HostRepoCatalogSnapshot | HostLineageSnapshot, + expected: DirectSshAuthority +): authority is Extract { + if (!authority.authoritative || authority.authority.kind !== 'direct-ssh') { + return false + } + return ( + authority.authority.executionHostId === toSshExecutionHostId(expected.targetId) && + directSshAuthoritiesEqual(authority.authority, expected) + ) +} + +function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapshot): AppState { + if (!snapshot.authoritative || snapshot.authority.kind !== 'direct-ssh') { + return state + } + const hostId = snapshot.authority.executionHostId + return { + ...state, + repos: [ + ...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), + ...snapshot.repos + ] + } +} + +function mergeExactHostLineage( + state: AppState, + snapshot: Extract, + authority: DirectSshAuthority, + catalogRevision: number +): AppState { + const scope = directSshHostHydrationScope(state, authority, catalogRevision) + const worktreeLineageById = Object.fromEntries( + Object.entries(state.worktreeLineageById).filter( + ([worktreeId]) => !scope.gitWorktreeIds.has(worktreeId) + ) + ) + const workspaceLineageByChildKey = Object.fromEntries( + Object.entries(state.workspaceLineageByChildKey).filter( + ([childKey]) => !isWorkspaceKey(childKey) || !scope.lineageWorkspaceKeys.has(childKey) + ) + ) + const incomingWorktreeLineage = Object.fromEntries( + Object.entries(snapshot.worktreeLineageById).filter(([worktreeId]) => + scope.gitWorktreeIds.has(worktreeId) + ) + ) + const incomingWorkspaceLineage = Object.fromEntries( + Object.entries(snapshot.workspaceLineageByChildKey).filter( + ([workspaceKey]) => + isWorkspaceKey(workspaceKey) && scope.lineageWorkspaceKeys.has(workspaceKey) + ) + ) + return { + ...state, + worktreeLineageById: { + ...worktreeLineageById, + ...incomingWorktreeLineage + }, + workspaceLineageByChildKey: { + ...workspaceLineageByChildKey, + ...incomingWorkspaceLineage + } + } +} + +export function createDirectSshHostHydration( + deps: DirectSshHostHydrationDeps +): DirectSshHostHydration { + const setTimer: NonNullable = + deps.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)) + const clearTimer: NonNullable = + deps.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType)) + const catalogRevisionByTarget = new Map() + const catalogInFlight = new Map>() + const pendingDeadlines = new Set<{ timer: HostReadTimer; settle: () => void }>() + let stopped = false + + const bounded = async (operation: Promise): Promise> => { + let pendingDeadline: { timer: HostReadTimer; settle: () => void } | undefined + const deadline = new Promise>((resolve) => { + const settle = (): void => resolve({ status: 'timed-out' }) + const timer = setTimer(settle, DIRECT_SSH_HOST_READ_TIMEOUT_MS) + pendingDeadline = { timer, settle } + pendingDeadlines.add(pendingDeadline) + }) + try { + return await Promise.race([ + operation.then, BoundedResult>( + (value) => ({ status: 'complete', value }), + () => ({ status: 'unavailable' }) + ), + deadline + ]) + } finally { + if (pendingDeadline) { + clearTimer(pendingDeadline.timer) + pendingDeadlines.delete(pendingDeadline) + } + } + } + + const refreshCatalog = ( + authority: DirectSshAuthority + ): Promise<'complete' | 'degraded' | 'stale'> => { + const key = JSON.stringify([ + authority.targetId, + authority.providerEpoch, + authority.connectionGeneration + ]) + const existing = catalogInFlight.get(key) + if (existing) { + return existing + } + const operation = (async (): Promise<'complete' | 'degraded' | 'stale'> => { + const boundedResult = await bounded(Promise.resolve().then(() => deps.listRepos(authority))) + if (stopped || !deps.isCurrentAuthority(authority)) { + return 'stale' + } + if (boundedResult.status !== 'complete') { + return 'degraded' + } + const snapshot = boundedResult.value + if (!snapshot.authoritative) { + return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' + } + if ( + !isAuthoritativeHost(snapshot, authority) || + snapshot.repos.some( + (repo) => + getRepoExecutionHostId(repo) !== toSshExecutionHostId(authority.targetId) || + repo.connectionId !== authority.targetId + ) + ) { + return 'stale' + } + let admitted = false + deps.store.setState((state) => { + if (stopped || !deps.isCurrentAuthority(authority)) { + return state + } + admitted = true + return mergeExactHostCatalog(state, snapshot) + }) + if (!admitted) { + return 'stale' + } + catalogRevisionByTarget.set( + authority.targetId, + (catalogRevisionByTarget.get(authority.targetId) ?? 0) + 1 + ) + return 'complete' + })().finally(() => { + if (catalogInFlight.get(key) === operation) { + catalogInFlight.delete(key) + } + }) + catalogInFlight.set(key, operation) + return operation + } + + const capturePreparationInput = async ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + snapshotRevision?: number + ): Promise => { + const catalogStartedAt = Date.now() + const catalogOutcome = await refreshCatalog(authority) + const catalogDurationMs = Math.max(0, Date.now() - catalogStartedAt) + if (catalogOutcome === 'stale' || stopped || !deps.isCurrentAuthority(authority)) { + return null + } + const catalogRevision = catalogRevisionByTarget.get(authority.targetId) ?? 0 + const scope = directSshHostHydrationScope(deps.store.getState(), authority, catalogRevision) + return { + ...authority, + catalogRevision, + repoRefs: scope.gitRepos, + authorityRequirement: 'required', + ...(snapshotRevision === undefined ? {} : { snapshotRevision }), + reason, + telemetry: directSshHostHydrationTelemetry(scope, catalogOutcome, catalogDurationMs) + } + } + + const readHostScopedLineage = async ( + input: DirectSshPreparationInput + ): Promise => { + const authority = { + targetId: input.targetId, + providerEpoch: input.providerEpoch, + connectionGeneration: input.connectionGeneration + } + const boundedResult = await bounded(Promise.resolve().then(() => deps.listLineage(authority))) + if ( + stopped || + !deps.isCurrentAuthority(authority) || + (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision + ) { + return 'stale' + } + if (boundedResult.status !== 'complete') { + return 'degraded' + } + const snapshot = boundedResult.value + if (!snapshot.authoritative) { + return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' + } + if (!isAuthoritativeHost(snapshot, authority)) { + return 'stale' + } + let admitted = false + deps.store.setState((state) => { + if ( + stopped || + !deps.isCurrentAuthority(authority) || + (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision + ) { + return state + } + admitted = true + return mergeExactHostLineage(state, snapshot, authority, input.catalogRevision) + }) + return admitted ? 'complete' : 'stale' + } + + return { + capturePreparationInput, + readHostScopedLineage, + isPreparationTokenCurrent: (token) => { + if ( + stopped || + !deps.isCurrentAuthority(token.authority) || + (catalogRevisionByTarget.get(token.authority.targetId) ?? 0) !== token.catalogRevision + ) { + return false + } + const scope = directSshHostHydrationScope( + deps.store.getState(), + token.authority, + token.catalogRevision + ) + const sortedRepos = [...scope.gitRepos].sort((left, right) => { + const leftKey = `${left.executionHostId}\0${left.repoId}` + const rightKey = `${right.executionHostId}\0${right.repoId}` + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0 + }) + return ( + JSON.stringify(sortedRepos.map((repo) => [repo.executionHostId, repo.repoId])) === + token.repoFingerprint + ) + }, + stop: () => { + stopped = true + for (const deadline of pendingDeadlines) { + clearTimer(deadline.timer) + deadline.settle() + } + pendingDeadlines.clear() + catalogInFlight.clear() + } + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts new file mode 100644 index 00000000000..8e5a411eded --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts @@ -0,0 +1,66 @@ +import type { + DirectSshPreparationMetrics, + DirectSshPreparationOutcome, + DirectSshReconnectOutcome, + DirectSshRepoOutcomeCounts +} from './direct-ssh-reconnect-coordinator-types' + +export function createEmptyDirectSshRepoOutcomeCounts(): DirectSshRepoOutcomeCounts { + return { + complete: 0, + 'non-authoritative': 0, + 'timed-out': 0, + 'cancel-budget-exhausted': 0, + canceled: 0, + stale: 0, + rejected: 0 + } +} + +export function createEmptyDirectSshPreparationMetrics(): DirectSshPreparationMetrics { + return { + queueWaitDurationsMs: [], + providerExecutionDurationsMs: [], + timeoutRetryCount: 0, + locallySettledWaiterCount: 0, + cancelDebtCount: 0, + replacementAdmissionDelayedCount: 0, + schedulerOverlappingJoinCount: 0, + peakLocallyUnsettled: 0, + estimatedLateWorkAllowanceCount: 0, + lineageDurationMs: 0 + } +} + +export function combineDirectSshReconnectOutcome( + prepared: DirectSshPreparationOutcome, + staleBindingsCleared: number, + retriedTerminals: number, + correctedTerminals: number +): DirectSshReconnectOutcome { + return { + ...prepared, + staleBindingsCleared, + retriedTerminals, + correctedTerminals, + stabilizing: false + } +} + +export function createTerminalOnlyDirectSshReconnectOutcome( + status: DirectSshReconnectOutcome['status'], + staleBindingsCleared = 0, + retriedTerminals = 0 +): DirectSshReconnectOutcome { + return { + status, + token: null, + repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), + lineageOutcome: 'not-started', + metrics: createEmptyDirectSshPreparationMetrics(), + staleBindingsCleared, + retriedTerminals, + correctedTerminals: 0, + stabilizing: status === 'stabilizing' + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts new file mode 100644 index 00000000000..9924e55d70b --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts @@ -0,0 +1,25 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { DirectSshReconnectTimer } from './direct-ssh-reconnect-coordinator-types' + +export type DirectSshReconnectTargetState = { + authority: DirectSshAuthority + installedAt: number + dampUntil: number | null + timer: DirectSshReconnectTimer | null +} + +export function createDirectSshReconnectTargetState( + authority: DirectSshAuthority, + previous: DirectSshReconnectTargetState | undefined, + installedAt: number, + stabilizationMs: number +): DirectSshReconnectTargetState { + const rotatedRapidly = + previous !== undefined && installedAt - previous.installedAt < stabilizationMs + return { + authority, + installedAt, + dampUntil: rotatedRapidly ? installedAt + stabilizationMs : null, + timer: null + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts new file mode 100644 index 00000000000..14bc881ced4 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts @@ -0,0 +1,118 @@ +import type { + DirectSshCoordinatorTelemetry, + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationReason, + DirectSshReconnectOutcome +} from './direct-ssh-reconnect-coordinator-types' +import { createTerminalOnlyDirectSshReconnectOutcome } from './direct-ssh-reconnect-coordinator-outcomes' + +type TelemetryOutcome = DirectSshPreparationOutcome | DirectSshReconnectOutcome + +type TelemetryOptions = { + terminalFinalizationDurationMs?: number + staleBindingsCleared?: number + retriedTerminals?: number + correctedTerminals?: number + damped?: boolean + authorityRotationCount?: number + catalogOutcome?: DirectSshCoordinatorTelemetry['catalogOutcome'] + catalogDurationMs?: number +} + +type DirectSshCoordinatorTelemetryReporter = { + report: ( + mode: DirectSshCoordinatorTelemetry['mode'], + input: DirectSshPreparationInput, + outcome: TelemetryOutcome, + startedAt: number, + options?: TelemetryOptions + ) => void + reportWithoutInput: ( + mode: DirectSshCoordinatorTelemetry['mode'], + reason: DirectSshPreparationReason, + outcome: TelemetryOutcome, + startedAt: number, + options?: TelemetryOptions + ) => void +} + +export function createDirectSshCoordinatorTelemetryReporter(args: { + onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void + now: () => number +}): DirectSshCoordinatorTelemetryReporter { + const emit = (event: DirectSshCoordinatorTelemetry): void => { + try { + args.onTelemetry?.(event) + } catch { + // Recovery cannot depend on diagnostics. + } + } + + const report = ( + mode: DirectSshCoordinatorTelemetry['mode'], + input: DirectSshPreparationInput, + outcome: TelemetryOutcome, + startedAt: number, + options: TelemetryOptions = {} + ): void => { + const inputMetrics = input.telemetry + const outcomeMetrics = + outcome.metrics ?? createTerminalOnlyDirectSshReconnectOutcome('stale').metrics! + emit({ + mode, + reason: input.reason, + outcome: outcome.status, + durationMs: Math.max(0, args.now() - startedAt), + staleBindingsCleared: options.staleBindingsCleared ?? 0, + retriedTerminals: options.retriedTerminals ?? 0, + correctedTerminals: options.correctedTerminals ?? 0, + terminalFinalizationDurationMs: options.terminalFinalizationDurationMs ?? 0, + catalogOutcome: options.catalogOutcome ?? inputMetrics?.catalogOutcome ?? 'complete', + catalogDurationMs: options.catalogDurationMs ?? inputMetrics?.catalogDurationMs ?? 0, + gitWorktreeCount: inputMetrics?.gitWorktreeCount ?? 0, + folderWorkspaceCount: inputMetrics?.folderWorkspaceCount ?? 0, + ambiguousOwnerCount: inputMetrics?.ambiguousOwnerCount ?? 0, + contradictoryOwnerCount: inputMetrics?.contradictoryOwnerCount ?? 0, + repoOutcomes: { ...outcome.repoOutcomes }, + lineageOutcome: outcome.lineageOutcome, + queueWaitDurationsMs: [...outcomeMetrics.queueWaitDurationsMs], + providerExecutionDurationsMs: [...outcomeMetrics.providerExecutionDurationsMs], + timeoutRetryCount: outcomeMetrics.timeoutRetryCount, + locallySettledWaiterCount: outcomeMetrics.locallySettledWaiterCount, + cancelDebtCount: outcomeMetrics.cancelDebtCount, + replacementAdmissionDelayedCount: outcomeMetrics.replacementAdmissionDelayedCount, + overlappingJoinCount: outcomeMetrics.schedulerOverlappingJoinCount, + peakLocallyUnsettled: outcomeMetrics.peakLocallyUnsettled, + estimatedLateWorkAllowanceCount: outcomeMetrics.estimatedLateWorkAllowanceCount, + authorityRotationCount: options.authorityRotationCount ?? 0, + damped: options.damped ?? false + }) + } + + const reportWithoutInput: DirectSshCoordinatorTelemetryReporter['reportWithoutInput'] = ( + mode, + reason, + outcome, + startedAt, + options = {} + ) => { + report( + mode, + { + targetId: '', + providerEpoch: '' as DirectSshPreparationInput['providerEpoch'], + connectionGeneration: 0, + catalogRevision: 0, + repoRefs: [], + authorityRequirement: 'required', + reason + }, + outcome, + startedAt, + options + ) + } + + return { report, reportWithoutInput } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts new file mode 100644 index 00000000000..6cc76174ed1 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts @@ -0,0 +1,152 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { DirectSshGitRepoRef } from '../lib/direct-ssh-target-scope-types' +import type { + DirectSshWorktreeRefreshScheduler, + DirectSshWorktreeRefreshTerminalStatus +} from './direct-ssh-worktree-refresh-scheduler' + +export type DirectSshPreparationReason = + | 'reconnect' + | 'initial-hydration' + | 'workspace-snapshot' + | 'wake-refresh' + +export type DirectSshAuthorityRequirement = 'required' | 'allow-metadata-fallback' + +export type DirectSshPreparationInput = DirectSshAuthority & { + catalogRevision: number + repoRefs: readonly DirectSshGitRepoRef[] + authorityRequirement: DirectSshAuthorityRequirement + snapshotRevision?: number + reason: DirectSshPreparationReason + telemetry?: DirectSshPreparationInputTelemetry +} + +export type DirectSshPreparationInputTelemetry = { + catalogOutcome: 'complete' | 'degraded' + catalogDurationMs: number + gitWorktreeCount: number + folderWorkspaceCount: number + ambiguousOwnerCount: number + contradictoryOwnerCount: number +} + +export type DirectSshPreparationToken = { + authority: DirectSshAuthority + catalogRevision: number + repoFingerprint: string + authorityRequirement: DirectSshAuthorityRequirement + snapshotRevision: number | null + outcome: 'complete' | 'degraded' +} + +export type DirectSshSnapshotApplyToken = DirectSshPreparationToken & { + snapshotRevision: number +} + +export type DirectSshLineageOutcome = 'complete' | 'degraded' | 'canceled' | 'stale' + +export type DirectSshRepoOutcomeCounts = Record + +export type DirectSshPreparationOutcome = { + status: 'complete' | 'degraded' | 'canceled' | 'stale' | 'stopped' + token: DirectSshPreparationToken | null + repoOutcomes: DirectSshRepoOutcomeCounts + lineageOutcome: DirectSshLineageOutcome | 'not-started' + metrics?: DirectSshPreparationMetrics +} + +export type DirectSshPreparationMetrics = { + queueWaitDurationsMs: readonly number[] + providerExecutionDurationsMs: readonly number[] + timeoutRetryCount: number + locallySettledWaiterCount: number + cancelDebtCount: number + replacementAdmissionDelayedCount: number + schedulerOverlappingJoinCount: number + peakLocallyUnsettled: number + estimatedLateWorkAllowanceCount: number + lineageDurationMs: number +} + +export type DirectSshReconnectOutcome = Omit & { + status: DirectSshPreparationOutcome['status'] | 'stabilizing' + staleBindingsCleared: number + retriedTerminals: number + correctedTerminals: number + stabilizing: boolean +} + +export type DirectSshCorrectionReason = + | 'preparation-complete' + | 'wake-refresh' + | 'workspace-hydrated' + | 'snapshot-applied' + +export type DirectSshCoordinatorTelemetry = { + mode: 'reconnect' | 'prepare-only' + reason: DirectSshPreparationReason + outcome: DirectSshReconnectOutcome['status'] + durationMs: number + staleBindingsCleared: number + retriedTerminals: number + correctedTerminals: number + terminalFinalizationDurationMs: number + catalogOutcome: 'complete' | 'degraded' | 'stale' + catalogDurationMs: number + gitWorktreeCount: number + folderWorkspaceCount: number + ambiguousOwnerCount: number + contradictoryOwnerCount: number + repoOutcomes: DirectSshRepoOutcomeCounts + lineageOutcome: DirectSshPreparationOutcome['lineageOutcome'] + queueWaitDurationsMs: readonly number[] + providerExecutionDurationsMs: readonly number[] + timeoutRetryCount: number + locallySettledWaiterCount: number + cancelDebtCount: number + replacementAdmissionDelayedCount: number + overlappingJoinCount: number + peakLocallyUnsettled: number + estimatedLateWorkAllowanceCount: number + authorityRotationCount: number + damped: boolean +} + +export type DirectSshReconnectTimer = unknown + +export type DirectSshReconnectCoordinatorDeps = { + scheduler: DirectSshWorktreeRefreshScheduler + isCurrentConnectedAuthority: (authority: DirectSshAuthority) => boolean + capturePreparationInput: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason + ) => Promise + readHostScopedLineage: (input: DirectSshPreparationInput) => Promise + invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number + retryTargetPanes: (authority: DirectSshAuthority) => number + finalizeHydratedTerminalPanes: (authority: DirectSshAuthority) => number + correctUnboundTerminalPanes: ( + authority: DirectSshAuthority, + reason: DirectSshCorrectionReason + ) => number + syncRemoteWorkspaceAfterConnect: (token: DirectSshPreparationToken) => void | Promise + onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void + now?: () => number + setTimer?: (callback: () => void, delayMs: number) => DirectSshReconnectTimer + clearTimer?: (timer: DirectSshReconnectTimer) => void + stabilizationMs?: number +} + +export type DirectSshReconnectCoordinator = { + requestReconnect: (authority: DirectSshAuthority) => Promise + prepareOnly: (input: DirectSshPreparationInput) => Promise + finalizeHydratedTerminals: (authority: DirectSshAuthority) => number + correctUnboundTerminals: ( + authority: DirectSshAuthority, + reason: DirectSshCorrectionReason + ) => number + replaceAuthority: (authority: DirectSshAuthority) => void + invalidate: (targetId: string) => void + stop: () => void +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts new file mode 100644 index 00000000000..e2f007e43b7 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts @@ -0,0 +1,566 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { + createDirectSshWorktreeRefreshScheduler, + type DirectSshWorktreeRefreshKey, + type DirectSshWorktreeRefreshLease, + type DirectSshWorktreeRefreshOutcome, + type DirectSshWorktreeRefreshScheduler, + type WaiterLeaseId +} from './direct-ssh-worktree-refresh-scheduler' +import { + admitDirectSshSnapshotApplyToken, + buildDirectSshSnapshotApplyToken, + createDirectSshReconnectCoordinator, + type DirectSshLineageOutcome, + type DirectSshPreparationInput, + type DirectSshReconnectCoordinatorDeps +} from './direct-ssh-reconnect-coordinator' + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function epoch(value: string): SshProviderEpoch { + return value as SshProviderEpoch +} + +function authority(targetId: string, epochId = `epoch-${targetId}`): DirectSshAuthority { + return { + targetId, + providerEpoch: epoch(epochId), + connectionGeneration: 1 + } +} + +function preparationInput( + owner: DirectSshAuthority, + repoIds: readonly string[] = ['repo'], + overrides: Partial = {} +): DirectSshPreparationInput { + return { + ...owner, + catalogRevision: 1, + repoRefs: repoIds.map((repoId) => ({ + repoId, + executionHostId: `ssh:${owner.targetId}` + })), + authorityRequirement: 'required', + reason: 'workspace-snapshot', + ...overrides + } +} + +type FakeLease = { + key: DirectSshWorktreeRefreshKey + deferred: Deferred + lease: DirectSshWorktreeRefreshLease +} + +function createFakeScheduler(): { + scheduler: DirectSshWorktreeRefreshScheduler + leases: FakeLease[] + complete: (index: number, status?: DirectSshWorktreeRefreshOutcome['status']) => void +} { + const leases: FakeLease[] = [] + const request = vi.fn((key: DirectSshWorktreeRefreshKey) => { + const leaseDeferred = deferred() + let released = false + const lease: DirectSshWorktreeRefreshLease = { + waiterLeaseId: `lease-${leases.length + 1}` as WaiterLeaseId, + result: leaseDeferred.promise, + release: vi.fn(() => { + if (!released) { + released = true + leaseDeferred.resolve({ status: 'canceled' }) + } + }) + } + leases.push({ key, deferred: leaseDeferred, lease }) + return lease + }) + const scheduler: DirectSshWorktreeRefreshScheduler = { + request, + invalidateAuthority: vi.fn(), + invalidateTarget: vi.fn(), + disposeProvider: vi.fn(), + getSnapshot: vi.fn(() => ({ + locallyUnsettled: 0, + queued: 0, + retrying: 0, + logicalTasks: leases.length, + waiters: leases.length, + cancelDebtByAuthority: new Map() + })), + stop: vi.fn() + } + return { + scheduler, + leases, + complete: (index, status = 'complete') => leases[index].deferred.resolve({ status }) + } +} + +function createCoordinatorHarness(fakeScheduler = createFakeScheduler()) { + const current = new Map() + const events: string[] = [] + const capturePreparationInput = vi.fn( + async (owner: DirectSshAuthority, reason: DirectSshPreparationInput['reason']) => { + events.push(`capture:${owner.targetId}`) + return preparationInput(owner, ['repo'], { reason }) + } + ) + const deps: DirectSshReconnectCoordinatorDeps = { + scheduler: fakeScheduler.scheduler, + isCurrentConnectedAuthority: (owner) => { + const active = current.get(owner.targetId) + return ( + active?.providerEpoch === owner.providerEpoch && + active.connectionGeneration === owner.connectionGeneration + ) + }, + capturePreparationInput, + readHostScopedLineage: vi.fn(async (): Promise => 'complete'), + invalidateStaleTerminalBindings: vi.fn((owner) => { + events.push(`invalidate:${owner.targetId}`) + return 1 + }), + retryTargetPanes: vi.fn((owner) => { + events.push(`retry:${owner.targetId}`) + return 2 + }), + finalizeHydratedTerminalPanes: vi.fn(() => 1), + correctUnboundTerminalPanes: vi.fn(() => 1), + syncRemoteWorkspaceAfterConnect: vi.fn(), + onTelemetry: vi.fn() + } + const coordinator = createDirectSshReconnectCoordinator(deps) + return { coordinator, deps, current, events, ...fakeScheduler } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createDirectSshReconnectCoordinator', () => { + it('runs terminal invalidation and retry synchronously before preparation awaits', () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + + void harness.coordinator.requestReconnect(owner) + + expect(harness.events).toEqual(['invalidate:target-a', 'retry:target-a', 'capture:target-a']) + expect(harness.leases).toHaveLength(0) + }) + + it('prepares the first observed authority immediately', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + + const pending = harness.coordinator.requestReconnect(owner) + await flush() + + expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(owner, 'reconnect') + expect(harness.leases).toHaveLength(1) + harness.complete(0) + await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) + }) + + it('keeps target B independent while target A preparation is blocked', async () => { + const harness = createCoordinatorHarness() + const targetA = authority('target-a') + const targetB = authority('target-b') + harness.current.set(targetA.targetId, targetA) + harness.current.set(targetB.targetId, targetB) + + const pendingA = harness.coordinator.requestReconnect(targetA) + await flush() + const pendingB = harness.coordinator.requestReconnect(targetB) + await flush() + expect(harness.leases).toHaveLength(2) + + harness.complete(1) + await expect(pendingB).resolves.toMatchObject({ status: 'complete' }) + expect(harness.deps.syncRemoteWorkspaceAfterConnect).toHaveBeenCalledTimes(1) + + let targetASettled = false + void pendingA.then(() => { + targetASettled = true + }) + await flush() + expect(targetASettled).toBe(false) + harness.coordinator.stop() + }) + + it('treats exact-equal authority replacement as a true timing no-op', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const harness = createCoordinatorHarness() + const owner = authority('target-a') + + harness.coordinator.replaceAuthority(owner) + vi.setSystemTime(4_000) + harness.coordinator.replaceAuthority({ ...owner }) + + expect(harness.scheduler.disposeProvider).not.toHaveBeenCalled() + expect(harness.scheduler.invalidateTarget).not.toHaveBeenCalled() + + vi.setSystemTime(5_001) + const next = authority('target-a', 'next') + harness.current.set(next.targetId, next) + const pending = harness.coordinator.requestReconnect(next) + await flush() + + expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(next, 'reconnect') + harness.complete(0) + await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) + }) + + it('cancels obsolete work on changed authority without ordering epochs', async () => { + const harness = createCoordinatorHarness() + const oldOwner = authority('target-a', 'z-old') + const newOwner = authority('target-a', 'a-new') + harness.current.set(oldOwner.targetId, oldOwner) + harness.coordinator.replaceAuthority(oldOwner) + const pending = harness.coordinator.prepareOnly(preparationInput(oldOwner)) + await flush() + + harness.current.set(newOwner.targetId, newOwner) + harness.coordinator.replaceAuthority(newOwner) + + await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) + expect(harness.leases[0].lease.release).toHaveBeenCalledWith('invalidated') + expect(harness.scheduler.disposeProvider).toHaveBeenCalledWith(oldOwner) + }) + + it('settles invalidation locally while a late lineage read remains pending', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + const lineage = deferred<'complete'>() + vi.mocked(harness.deps.readHostScopedLineage).mockReturnValue(lineage.promise) + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly(preparationInput(owner)) + harness.complete(0) + await flush() + expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() + + harness.coordinator.invalidate(owner.targetId) + await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) + + lineage.resolve('complete') + await flush() + expect(harness.deps.syncRemoteWorkspaceAfterConnect).not.toHaveBeenCalled() + }) + + it('joins exact overlapping preparation and reruns after completion', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const input = preparationInput(owner) + + const first = harness.coordinator.prepareOnly(input) + const joined = harness.coordinator.prepareOnly({ + ...input, + repoRefs: input.repoRefs.toReversed() + }) + expect(joined).toBe(first) + expect(harness.leases).toHaveLength(1) + + harness.complete(0) + await expect(first).resolves.toMatchObject({ status: 'complete' }) + const later = harness.coordinator.prepareOnly(input) + expect(later).not.toBe(first) + expect(harness.leases).toHaveLength(2) + harness.coordinator.stop() + }) + + it('keeps prepare-only isolated from terminal retry and reconnect sync', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly(preparationInput(owner)) + harness.complete(0, 'non-authoritative') + + await expect(pending).resolves.toMatchObject({ + status: 'degraded', + token: { outcome: 'degraded' } + }) + expect(harness.deps.invalidateStaleTerminalBindings).not.toHaveBeenCalled() + expect(harness.deps.retryTargetPanes).not.toHaveBeenCalled() + expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() + expect(harness.deps.syncRemoteWorkspaceAfterConnect).not.toHaveBeenCalled() + }) + + it('rejects repo refs for another SSH host before scheduler admission', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + + await expect( + harness.coordinator.prepareOnly( + preparationInput(owner, ['repo'], { + repoRefs: [{ repoId: 'repo', executionHostId: 'ssh:target-b' }] + }) + ) + ).resolves.toMatchObject({ status: 'stale', token: null }) + + expect(harness.leases).toHaveLength(0) + expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() + }) + + it('holds lineage and token creation through the scheduler timeout retry barrier', async () => { + const attempts: { + id: ProviderRequestId + deferred: Deferred + key: DirectSshWorktreeRefreshKey + }[] = [] + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt: (attemptKey) => { + const result = deferred() + const id = `provider-${attempts.length + 1}` as ProviderRequestId + attempts.push({ id, deferred: result, key: attemptKey }) + return { providerRequestId: id, result: result.promise, cancel: vi.fn() } + } + }) + const harness = createCoordinatorHarness({ + scheduler, + leases: [], + complete: () => {} + }) + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly(preparationInput(owner)) + + attempts[0].deferred.resolve({ + status: 'timed-out', + providerRequestId: attempts[0].id, + executionHostId: attempts[0].key.executionHostId + }) + await flush() + expect(attempts).toHaveLength(2) + expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() + + attempts[1].deferred.resolve({ + status: 'complete', + providerRequestId: attempts[1].id, + repoId: 'repo', + authority: { + kind: 'direct-ssh', + executionHostId: attempts[1].key.executionHostId, + ...owner + }, + result: { repoId: 'repo', authoritative: true, source: 'git', worktrees: [] } + }) + await expect(pending).resolves.toMatchObject({ status: 'complete', token: {} }) + expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() + }) + + it('starts lineage only after every repo is terminal and returns a fenced token', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly( + preparationInput(owner, ['repo-b', 'repo-a'], { + catalogRevision: 7, + snapshotRevision: 11 + }) + ) + expect(harness.leases.map((lease) => lease.key.repoId)).toEqual(['repo-a', 'repo-b']) + + harness.complete(0) + await flush() + expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() + harness.complete(1) + + await expect(pending).resolves.toMatchObject({ + status: 'complete', + token: { + authority: owner, + catalogRevision: 7, + snapshotRevision: 11, + outcome: 'complete' + } + }) + expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() + }) + + it('requires exact snapshot revision and authority admission', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const pending = harness.coordinator.prepareOnly( + preparationInput(owner, [], { snapshotRevision: 5 }) + ) + const prepared = await pending + const token = prepared.token! + + expect(buildDirectSshSnapshotApplyToken(token, 6)).toBeNull() + const exact = buildDirectSshSnapshotApplyToken(token, 5)! + expect(admitDirectSshSnapshotApplyToken(exact, owner, 5)).toBe(true) + expect(admitDirectSshSnapshotApplyToken(exact, owner, 6)).toBe(false) + expect(admitDirectSshSnapshotApplyToken(exact, authority('target-a', 'other'), 5)).toBe(false) + }) + + it('damps rapid rotations to the latest authority while terminal checks stay immediate', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const harness = createCoordinatorHarness() + const initial = authority('target-a', 'initial') + const middle = authority('target-a', 'middle') + const flapping = authority('target-a', 'flapping') + const latest = authority('target-a', 'latest') + harness.current.set(initial.targetId, initial) + harness.coordinator.replaceAuthority(initial) + + vi.setSystemTime(5_001) + harness.current.set(middle.targetId, middle) + const stableReplacement = harness.coordinator.requestReconnect(middle) + await flush() + harness.complete(0) + await expect(stableReplacement).resolves.toMatchObject({ + status: 'complete', + stabilizing: false + }) + + vi.setSystemTime(6_000) + harness.current.set(flapping.targetId, flapping) + await expect(harness.coordinator.requestReconnect(flapping)).resolves.toMatchObject({ + status: 'stabilizing' + }) + vi.setSystemTime(7_000) + harness.current.set(latest.targetId, latest) + await expect(harness.coordinator.requestReconnect(latest)).resolves.toMatchObject({ + status: 'stabilizing' + }) + + expect(harness.deps.retryTargetPanes).toHaveBeenCalledTimes(3) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(4_999) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(2) + expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(latest, 'reconnect') + expect(harness.deps.capturePreparationInput).not.toHaveBeenCalledWith(flapping, 'reconnect') + harness.complete(1) + }) + + it('corrects only current authority and cleans pending work and timers on stop', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const harness = createCoordinatorHarness() + const initial = authority('target-a', 'initial') + const latest = authority('target-a', 'latest') + harness.current.set(initial.targetId, initial) + harness.coordinator.replaceAuthority(initial) + harness.current.set(latest.targetId, latest) + void harness.coordinator.requestReconnect(latest) + + expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(1) + expect(harness.coordinator.finalizeHydratedTerminals(initial)).toBe(0) + harness.coordinator.stop() + await vi.advanceTimersByTimeAsync(5_000) + + expect(harness.deps.capturePreparationInput).not.toHaveBeenCalled() + expect(harness.scheduler.stop).toHaveBeenCalledOnce() + expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(0) + }) + + it('emits one aggregate for joined work with scheduler and scope metrics', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + harness.coordinator.replaceAuthority(owner) + const input = preparationInput(owner, ['repo'], { + telemetry: { + catalogOutcome: 'degraded', + catalogDurationMs: 12, + gitWorktreeCount: 3, + folderWorkspaceCount: 2, + ambiguousOwnerCount: 1, + contradictoryOwnerCount: 1 + } + }) + + const first = harness.coordinator.prepareOnly(input) + const joined = harness.coordinator.prepareOnly(input) + harness.leases[0].deferred.resolve({ + status: 'complete', + metrics: { + queueWaitDurationsMs: [8], + providerExecutionDurationsMs: [13], + timeoutRetryCount: 1, + locallySettledWaiterCount: 1, + cancelDebtCount: 1, + replacementAdmissionDelayedCount: 1, + overlappingJoinCount: 2, + peakLocallyUnsettled: 4, + estimatedLateWorkAllowanceCount: 1 + } + }) + await Promise.all([first, joined]) + + expect(harness.deps.onTelemetry).toHaveBeenCalledOnce() + expect(harness.deps.onTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'prepare-only', + catalogOutcome: 'degraded', + catalogDurationMs: 12, + gitWorktreeCount: 3, + folderWorkspaceCount: 2, + queueWaitDurationsMs: [8], + providerExecutionDurationsMs: [13], + timeoutRetryCount: 1, + cancelDebtCount: 1, + replacementAdmissionDelayedCount: 1, + overlappingJoinCount: 3, + peakLocallyUnsettled: 4 + }) + ) + }) + + it('does not let a telemetry callback failure affect recovery', async () => { + const harness = createCoordinatorHarness() + const owner = authority('target-a') + harness.current.set(owner.targetId, owner) + vi.mocked(harness.deps.onTelemetry!).mockImplementation(() => { + throw new Error('telemetry unavailable') + }) + + const pending = harness.coordinator.requestReconnect(owner) + await flush() + harness.complete(0) + + await expect(pending).resolves.toMatchObject({ status: 'complete' }) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts new file mode 100644 index 00000000000..7ddf3892ff8 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts @@ -0,0 +1,312 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { + createDirectSshPreparationCoordinator, + type DirectSshPreparationCoordinator +} from './direct-ssh-reconnect-preparation' +import { + combineDirectSshReconnectOutcome, + createEmptyDirectSshRepoOutcomeCounts, + createTerminalOnlyDirectSshReconnectOutcome +} from './direct-ssh-reconnect-coordinator-outcomes' +import { + createDirectSshReconnectTargetState, + type DirectSshReconnectTargetState +} from './direct-ssh-reconnect-coordinator-stabilization' +import { createDirectSshCoordinatorTelemetryReporter } from './direct-ssh-reconnect-coordinator-telemetry' +import type { + DirectSshCorrectionReason, + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationReason, + DirectSshReconnectCoordinator, + DirectSshReconnectCoordinatorDeps, + DirectSshReconnectOutcome, + DirectSshReconnectTimer +} from './direct-ssh-reconnect-coordinator-types' +import { + directSshAuthoritiesEqual, + isDirectSshPreparationInputHostConsistent, + normalizeDirectSshPreparationInput +} from './direct-ssh-reconnect-tokens' +export type * from './direct-ssh-reconnect-coordinator-types' +export { + admitDirectSshSnapshotApplyToken, + buildDirectSshSnapshotApplyToken +} from './direct-ssh-reconnect-tokens' + +export const DIRECT_SSH_RELAY_STABILIZATION_MS = 5_000 + +export function createDirectSshReconnectCoordinator( + deps: DirectSshReconnectCoordinatorDeps +): DirectSshReconnectCoordinator { + const now = deps.now ?? Date.now + const setTimer = + deps.setTimer ?? ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)) + const clearTimer = + deps.clearTimer ?? + ((timer: DirectSshReconnectTimer) => clearTimeout(timer as ReturnType)) + const stabilizationMs = deps.stabilizationMs ?? DIRECT_SSH_RELAY_STABILIZATION_MS + const targets = new Map() + let stopped = false + + const isCurrent = (authority: DirectSshAuthority): boolean => { + const state = targets.get(authority.targetId) + return ( + !stopped && + directSshAuthoritiesEqual(state?.authority, authority) && + deps.isCurrentConnectedAuthority(authority) + ) + } + + const preparation: DirectSshPreparationCoordinator = createDirectSshPreparationCoordinator({ + scheduler: deps.scheduler, + isCurrentAuthority: isCurrent, + readLineage: deps.readHostScopedLineage, + now + }) + const telemetry = createDirectSshCoordinatorTelemetryReporter({ + onTelemetry: deps.onTelemetry, + now + }) + + const replaceAuthority = (authority: DirectSshAuthority): boolean => { + if (stopped) { + return false + } + const previous = targets.get(authority.targetId) + if (directSshAuthoritiesEqual(previous?.authority, authority)) { + return false + } + if (previous) { + if (previous.timer) { + clearTimer(previous.timer) + } + preparation.invalidateAuthority(previous.authority) + deps.scheduler.disposeProvider(previous.authority) + } + const installedAt = now() + targets.set( + authority.targetId, + createDirectSshReconnectTargetState(authority, previous, installedAt, stabilizationMs) + ) + return previous !== undefined + } + + const captureInput = async ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason + ): Promise => { + const captured = await deps.capturePreparationInput(authority, reason) + if ( + !captured || + !directSshAuthoritiesEqual(captured, authority) || + !isDirectSshPreparationInputHostConsistent(captured) || + !isCurrent(authority) + ) { + return null + } + return normalizeDirectSshPreparationInput({ ...captured, reason }) + } + + const startSync = (token: NonNullable): void => { + try { + void Promise.resolve(deps.syncRemoteWorkspaceAfterConnect(token)).catch(() => {}) + } catch { + // Sync failure is reported by its owning boundary. + } + } + + const runPreparedReconnect = async ( + authority: DirectSshAuthority, + staleBindingsCleared: number, + retriedTerminals: number, + damped: boolean, + operationStartedAt = now(), + terminalFinalizationDurationMs = 0, + authorityRotationCount = 0 + ): Promise => { + const input = await captureInput(authority, 'reconnect') + if (!input) { + const outcome = createTerminalOnlyDirectSshReconnectOutcome( + 'stale', + staleBindingsCleared, + retriedTerminals + ) + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, operationStartedAt, { + staleBindingsCleared, + retriedTerminals, + terminalFinalizationDurationMs, + catalogOutcome: 'stale', + catalogDurationMs: Math.max(0, now() - operationStartedAt), + authorityRotationCount, + damped + }) + return outcome + } + const acquired = preparation.acquire(input) + const prepared = await acquired.promise + let correctedTerminals = 0 + if (prepared.token && isCurrent(authority)) { + correctedTerminals = deps.correctUnboundTerminalPanes(authority, 'preparation-complete') + if (isCurrent(authority)) { + startSync(prepared.token) + } + } + const outcome = combineDirectSshReconnectOutcome( + prepared, + staleBindingsCleared, + retriedTerminals, + correctedTerminals + ) + if (!acquired.joined) { + telemetry.report('reconnect', input, prepared, operationStartedAt, { + terminalFinalizationDurationMs, + staleBindingsCleared, + retriedTerminals, + correctedTerminals, + damped, + authorityRotationCount + }) + } + return outcome + } + + const runDelayedPreparation = async (authority: DirectSshAuthority): Promise => { + if (!isCurrent(authority)) { + return + } + await runPreparedReconnect(authority, 0, 0, true, now(), 0, 1) + } + + const scheduleLatestPreparation = (state: DirectSshReconnectTargetState): void => { + if (state.timer || state.dampUntil === null) { + return + } + const delayMs = Math.max(0, state.dampUntil - now()) + state.timer = setTimer(() => { + state.timer = null + state.dampUntil = null + void runDelayedPreparation(state.authority) + }, delayMs) + } + + const requestReconnect = async ( + authority: DirectSshAuthority + ): Promise => { + const startedAt = now() + if (stopped || !deps.isCurrentConnectedAuthority(authority)) { + const outcome = createTerminalOnlyDirectSshReconnectOutcome(stopped ? 'stopped' : 'stale') + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { + catalogOutcome: 'degraded' + }) + return outcome + } + const rotated = replaceAuthority(authority) + if (!isCurrent(authority)) { + const outcome = createTerminalOnlyDirectSshReconnectOutcome('stale') + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { + authorityRotationCount: rotated ? 1 : 0 + }) + return outcome + } + const terminalStartedAt = now() + const staleBindingsCleared = deps.invalidateStaleTerminalBindings(authority) + const retriedTerminals = deps.retryTargetPanes(authority) + const terminalFinalizationDurationMs = Math.max(0, now() - terminalStartedAt) + const state = targets.get(authority.targetId)! + if (state.dampUntil !== null && now() < state.dampUntil) { + scheduleLatestPreparation(state) + const outcome = createTerminalOnlyDirectSshReconnectOutcome( + 'stabilizing', + staleBindingsCleared, + retriedTerminals + ) + telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { + staleBindingsCleared, + retriedTerminals, + terminalFinalizationDurationMs, + catalogOutcome: 'degraded', + authorityRotationCount: rotated ? 1 : 0, + damped: true + }) + return outcome + } + return runPreparedReconnect( + authority, + staleBindingsCleared, + retriedTerminals, + false, + startedAt, + terminalFinalizationDurationMs, + rotated ? 1 : 0 + ) + } + + const prepareOnly = ( + rawInput: DirectSshPreparationInput + ): Promise => { + const input = normalizeDirectSshPreparationInput(rawInput) + if (stopped || !isCurrent(input) || !isDirectSshPreparationInputHostConsistent(input)) { + const outcome: DirectSshPreparationOutcome = { + status: stopped ? 'stopped' : 'stale', + token: null, + repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), + lineageOutcome: 'not-started', + metrics: createTerminalOnlyDirectSshReconnectOutcome('stale').metrics + } + telemetry.report('prepare-only', input, outcome, now()) + return Promise.resolve(outcome) + } + const startedAt = now() + const acquired = preparation.acquire(input) + if (!acquired.joined) { + void acquired.promise.then((outcome) => { + telemetry.report('prepare-only', input, outcome, startedAt) + }) + } + return acquired.promise + } + + const finalizeHydratedTerminals = (authority: DirectSshAuthority): number => + isCurrent(authority) ? deps.finalizeHydratedTerminalPanes(authority) : 0 + + const correctUnboundTerminals = ( + authority: DirectSshAuthority, + reason: DirectSshCorrectionReason + ): number => (isCurrent(authority) ? deps.correctUnboundTerminalPanes(authority, reason) : 0) + + const invalidate = (targetId: string): void => { + const state = targets.get(targetId) + if (state?.timer) { + clearTimer(state.timer) + } + preparation.invalidateTarget(targetId) + deps.scheduler.invalidateTarget(targetId) + targets.delete(targetId) + } + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + for (const state of targets.values()) { + if (state.timer) { + clearTimer(state.timer) + } + } + preparation.stop() + deps.scheduler.stop() + targets.clear() + } + + return { + requestReconnect, + prepareOnly, + finalizeHydratedTerminals, + correctUnboundTerminals, + replaceAuthority, + invalidate, + stop + } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts new file mode 100644 index 00000000000..6068319fc32 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts @@ -0,0 +1,39 @@ +import type { DirectSshWorktreeRefreshOutcome } from './direct-ssh-worktree-refresh-scheduler' +import type { DirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-types' +import { createEmptyDirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-outcomes' + +export function aggregateDirectSshPreparationMetrics( + outcomes: readonly DirectSshWorktreeRefreshOutcome[], + joinCount: number +): DirectSshPreparationMetrics { + const aggregate = createEmptyDirectSshPreparationMetrics() + for (const outcome of outcomes) { + const metrics = outcome.metrics + if (!metrics) { + continue + } + aggregate.queueWaitDurationsMs = [ + ...aggregate.queueWaitDurationsMs, + ...metrics.queueWaitDurationsMs + ] + aggregate.providerExecutionDurationsMs = [ + ...aggregate.providerExecutionDurationsMs, + ...metrics.providerExecutionDurationsMs + ] + aggregate.timeoutRetryCount += metrics.timeoutRetryCount + aggregate.locallySettledWaiterCount += metrics.locallySettledWaiterCount + aggregate.cancelDebtCount += metrics.cancelDebtCount + aggregate.replacementAdmissionDelayedCount += metrics.replacementAdmissionDelayedCount + aggregate.schedulerOverlappingJoinCount += metrics.overlappingJoinCount + aggregate.peakLocallyUnsettled = Math.max( + aggregate.peakLocallyUnsettled, + metrics.peakLocallyUnsettled + ) + aggregate.estimatedLateWorkAllowanceCount = Math.max( + aggregate.estimatedLateWorkAllowanceCount, + metrics.estimatedLateWorkAllowanceCount + ) + } + aggregate.schedulerOverlappingJoinCount += joinCount + return aggregate +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts new file mode 100644 index 00000000000..e18dced3112 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts @@ -0,0 +1,291 @@ +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DirectSshWorktreeRefreshLease, + DirectSshWorktreeRefreshOutcome, + DirectSshWorktreeRefreshScheduler, + DirectSshWorktreeRefreshTerminalStatus +} from './direct-ssh-worktree-refresh-scheduler' +import type { + DirectSshLineageOutcome, + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationToken, + DirectSshRepoOutcomeCounts +} from './direct-ssh-reconnect-coordinator-types' +import { + createEmptyDirectSshPreparationMetrics, + createEmptyDirectSshRepoOutcomeCounts +} from './direct-ssh-reconnect-coordinator-outcomes' +import { aggregateDirectSshPreparationMetrics } from './direct-ssh-reconnect-preparation-metrics' +import { + directSshAuthoritiesEqual, + directSshPreparationOperationKey, + directSshRepoFingerprint, + normalizeDirectSshPreparationInput +} from './direct-ssh-reconnect-tokens' + +type PreparationOperation = { + key: string + input: DirectSshPreparationInput + leases: DirectSshWorktreeRefreshLease[] + joinCount: number + invalidatedAs: 'stale' | 'stopped' | null + settleInvalidation: (reason: 'stale' | 'stopped') => void + invalidation: Promise<'stale' | 'stopped'> + promise: Promise +} + +export type DirectSshPreparationCoordinator = { + acquire: (input: DirectSshPreparationInput) => { + promise: Promise + joined: boolean + } + invalidateAuthority: (authority: DirectSshAuthority) => void + invalidateTarget: (targetId: string) => void + stop: () => void +} + +type PreparationCoordinatorDeps = { + scheduler: DirectSshWorktreeRefreshScheduler + isCurrentAuthority: (authority: DirectSshAuthority) => boolean + readLineage: (input: DirectSshPreparationInput) => Promise + now?: () => number +} + +const TERMINAL_STATUSES: readonly DirectSshWorktreeRefreshTerminalStatus[] = [ + 'complete', + 'non-authoritative', + 'timed-out', + 'cancel-budget-exhausted', + 'canceled', + 'stale', + 'rejected' +] + +function countRepoOutcomes( + outcomes: readonly DirectSshWorktreeRefreshOutcome[] +): DirectSshRepoOutcomeCounts { + const counts = createEmptyDirectSshRepoOutcomeCounts() + for (const outcome of outcomes) { + counts[outcome.status]++ + } + return counts +} + +function awaitRepoLeases( + leases: readonly DirectSshWorktreeRefreshLease[] +): Promise { + if (leases.length === 0) { + return Promise.resolve([]) + } + return new Promise((resolve) => { + const outcomes: DirectSshWorktreeRefreshOutcome[] = [] + let remaining = leases.length + leases.forEach((lease, index) => { + void lease.result.then( + (outcome) => { + outcomes[index] = outcome + remaining-- + if (remaining === 0) { + resolve(outcomes) + } + }, + () => { + outcomes[index] = { status: 'rejected' } + remaining-- + if (remaining === 0) { + resolve(outcomes) + } + } + ) + }) + }) +} + +function terminalPreparationOutcome( + status: 'canceled' | 'stale' | 'stopped', + repoOutcomes: DirectSshRepoOutcomeCounts, + metrics = createEmptyDirectSshPreparationMetrics() +): DirectSshPreparationOutcome { + return { + status, + token: null, + repoOutcomes, + lineageOutcome: 'not-started', + metrics + } +} + +function buildPreparationToken( + input: DirectSshPreparationInput, + outcome: 'complete' | 'degraded' +): DirectSshPreparationToken { + return { + authority: { + targetId: input.targetId, + providerEpoch: input.providerEpoch, + connectionGeneration: input.connectionGeneration + }, + catalogRevision: input.catalogRevision, + repoFingerprint: directSshRepoFingerprint(input), + authorityRequirement: input.authorityRequirement, + snapshotRevision: input.snapshotRevision ?? null, + outcome + } +} + +function hasOutcome( + counts: DirectSshRepoOutcomeCounts, + statuses: readonly DirectSshWorktreeRefreshTerminalStatus[] +): boolean { + return statuses.some((status) => counts[status] > 0) +} + +export function createDirectSshPreparationCoordinator( + deps: PreparationCoordinatorDeps +): DirectSshPreparationCoordinator { + const now = deps.now ?? Date.now + const operations = new Map() + let stopped = false + + const run = async (operation: PreparationOperation): Promise => { + const { input } = operation + operation.leases = input.repoRefs.map((repo) => + deps.scheduler.request({ + targetId: input.targetId, + providerEpoch: input.providerEpoch, + connectionGeneration: input.connectionGeneration, + repoId: repo.repoId, + executionHostId: repo.executionHostId, + catalogRevision: input.catalogRevision, + authorityRequirement: input.authorityRequirement + }) + ) + const outcomes = await awaitRepoLeases(operation.leases) + const repoOutcomes = countRepoOutcomes(outcomes) + const metrics = aggregateDirectSshPreparationMetrics(outcomes, operation.joinCount) + if (operation.invalidatedAs) { + return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) + } + if (!deps.isCurrentAuthority(input)) { + return terminalPreparationOutcome('stale', repoOutcomes, metrics) + } + if (hasOutcome(repoOutcomes, ['canceled', 'stale'])) { + const status = repoOutcomes.stale > 0 ? 'stale' : 'canceled' + return terminalPreparationOutcome(status, repoOutcomes, metrics) + } + + let lineageOutcome: DirectSshLineageOutcome + const lineageStartedAt = now() + try { + lineageOutcome = await deps.readLineage(input) + } catch { + lineageOutcome = 'degraded' + } + metrics.lineageDurationMs = Math.max(0, now() - lineageStartedAt) + if (operation.invalidatedAs) { + return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) + } + if (!deps.isCurrentAuthority(input) || lineageOutcome === 'stale') { + return terminalPreparationOutcome('stale', repoOutcomes, metrics) + } + if (lineageOutcome === 'canceled') { + return terminalPreparationOutcome('canceled', repoOutcomes, metrics) + } + const degraded = + lineageOutcome === 'degraded' || + TERMINAL_STATUSES.some((status) => status !== 'complete' && repoOutcomes[status] > 0) + const status = degraded ? 'degraded' : 'complete' + return { + status, + token: buildPreparationToken(input, status), + repoOutcomes, + lineageOutcome, + metrics + } + } + + const acquire = ( + rawInput: DirectSshPreparationInput + ): { promise: Promise; joined: boolean } => { + const input = normalizeDirectSshPreparationInput(rawInput) + const key = directSshPreparationOperationKey(input) + const current = operations.get(key) + if (current) { + current.joinCount++ + return { promise: current.promise, joined: true } + } + if (stopped) { + return { + promise: Promise.resolve( + terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) + ), + joined: false + } + } + const operation: PreparationOperation = { + key, + input, + leases: [], + joinCount: 0, + invalidatedAs: null, + settleInvalidation: () => {}, + invalidation: Promise.resolve('stopped'), + promise: Promise.resolve( + terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) + ) + } + operation.invalidation = new Promise((resolve) => { + operation.settleInvalidation = resolve + }) + operations.set(key, operation) + operation.promise = Promise.race([ + run(operation), + operation.invalidation.then((reason) => + terminalPreparationOutcome(reason, createEmptyDirectSshRepoOutcomeCounts()) + ) + ]).finally(() => { + if (operations.get(key) === operation) { + operations.delete(key) + } + }) + return { promise: operation.promise, joined: false } + } + + const invalidateMatching = ( + predicate: (operation: PreparationOperation) => boolean, + reason: 'stale' | 'stopped' + ): void => { + for (const operation of operations.values()) { + if (!predicate(operation)) { + continue + } + operation.invalidatedAs = reason + operation.settleInvalidation(reason) + for (const lease of operation.leases) { + lease.release(reason === 'stopped' ? 'stopped' : 'invalidated') + } + } + } + + const invalidateAuthority = (authority: DirectSshAuthority): void => { + invalidateMatching( + (operation) => directSshAuthoritiesEqual(operation.input, authority), + 'stale' + ) + } + + const invalidateTarget = (targetId: string): void => { + invalidateMatching((operation) => operation.input.targetId === targetId, 'stale') + } + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + invalidateMatching(() => true, 'stopped') + } + + return { acquire, invalidateAuthority, invalidateTarget, stop } +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts new file mode 100644 index 00000000000..a835ea81856 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY, + isDirectSshReconnectCoordinatorRoutingEnabled, + resolveDirectSshReconnectCoordinatorRouting +} from './direct-ssh-reconnect-rollout' + +describe('direct SSH reconnect rollout', () => { + it('defaults coordinator routing on', () => { + expect(resolveDirectSshReconnectCoordinatorRouting({})).toBe(true) + expect(resolveDirectSshReconnectCoordinatorRouting({ buildValue: 'true' })).toBe(true) + }) + + it('supports build and session disable routes without session re-enablement', () => { + expect( + resolveDirectSshReconnectCoordinatorRouting({ + buildValue: 'false', + sessionValue: 'true' + }) + ).toBe(false) + expect( + resolveDirectSshReconnectCoordinatorRouting({ + buildValue: 'true', + sessionValue: 'false' + }) + ).toBe(false) + }) + + it('reads the current session route and fails open when storage is unavailable', () => { + const getItem = vi.fn(() => 'false') + vi.stubGlobal('sessionStorage', { getItem }) + + expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(false) + expect(getItem).toHaveBeenCalledWith(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) + + getItem.mockImplementation(() => { + throw new Error('blocked') + }) + expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(true) + vi.unstubAllGlobals() + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts new file mode 100644 index 00000000000..aa8645cb7ba --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts @@ -0,0 +1,25 @@ +export const DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY = 'orca.directSshReconnectCoordinator.enabled' + +export function resolveDirectSshReconnectCoordinatorRouting(args: { + buildValue?: string + sessionValue?: string | null +}): boolean { + if (args.buildValue?.trim().toLowerCase() === 'false') { + return false + } + return args.sessionValue?.trim().toLowerCase() !== 'false' +} + +export function isDirectSshReconnectCoordinatorRoutingEnabled(): boolean { + let sessionValue: string | null = null + try { + sessionValue = + globalThis.sessionStorage?.getItem(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) ?? null + } catch { + // Storage can be unavailable in privacy-restricted renderer sessions. + } + return resolveDirectSshReconnectCoordinatorRouting({ + buildValue: import.meta.env.VITE_DIRECT_SSH_RECONNECT_COORDINATOR, + sessionValue + }) +} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts new file mode 100644 index 00000000000..98790b7362e --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' + +const authority: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 +} + +describe('directSshAuthoritiesEqual', () => { + it('fails closed when either authority is missing', () => { + expect(directSshAuthoritiesEqual(null, null)).toBe(false) + expect(directSshAuthoritiesEqual(authority, null)).toBe(false) + expect(directSshAuthoritiesEqual(undefined, authority)).toBe(false) + }) + + it('requires the complete authority tuple', () => { + expect(directSshAuthoritiesEqual(authority, { ...authority })).toBe(true) + expect( + directSshAuthoritiesEqual(authority, { + ...authority, + connectionGeneration: authority.connectionGeneration + 1 + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts new file mode 100644 index 00000000000..f9627268425 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts @@ -0,0 +1,83 @@ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DirectSshPreparationInput, + DirectSshPreparationToken, + DirectSshSnapshotApplyToken +} from './direct-ssh-reconnect-coordinator-types' + +export function directSshAuthoritiesEqual( + left: DirectSshAuthority | null | undefined, + right: DirectSshAuthority | null | undefined +): boolean { + if (!left || !right) { + return false + } + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +export function normalizeDirectSshPreparationInput( + input: DirectSshPreparationInput +): DirectSshPreparationInput { + return { + ...input, + repoRefs: [...input.repoRefs].sort( + (left, right) => + compareText(left.executionHostId, right.executionHostId) || + compareText(left.repoId, right.repoId) + ) + } +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function isDirectSshPreparationInputHostConsistent( + input: DirectSshPreparationInput +): boolean { + const expectedHost = toSshExecutionHostId(input.targetId) + return input.repoRefs.every((repo) => repo.executionHostId === expectedHost) +} + +export function directSshRepoFingerprint(input: DirectSshPreparationInput): string { + return JSON.stringify(input.repoRefs.map((repo) => [repo.executionHostId, repo.repoId])) +} + +export function directSshPreparationOperationKey(input: DirectSshPreparationInput): string { + return JSON.stringify([ + input.targetId, + input.providerEpoch, + input.connectionGeneration, + input.catalogRevision, + directSshRepoFingerprint(input), + input.authorityRequirement, + input.snapshotRevision ?? null, + input.reason + ]) +} + +export function buildDirectSshSnapshotApplyToken( + token: DirectSshPreparationToken, + snapshotRevision: number +): DirectSshSnapshotApplyToken | null { + if (token.snapshotRevision !== null && token.snapshotRevision !== snapshotRevision) { + return null + } + return { ...token, snapshotRevision } +} + +export function admitDirectSshSnapshotApplyToken( + token: DirectSshSnapshotApplyToken, + authority: DirectSshAuthority, + snapshotRevision: number +): boolean { + return ( + token.snapshotRevision === snapshotRevision && + directSshAuthoritiesEqual(token.authority, authority) + ) +} diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx new file mode 100644 index 00000000000..75c26fe627c --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { createTestStore, makeTab, makeWorktree } from '@/store/slices/store-test-helpers' +import { useRemoteRuntimeRecoveryTriggers } from '@/runtime/use-remote-runtime-recovery-triggers' +import { registerDirectSshWakeRouting } from './direct-ssh-state-routing' + +const { retryAllRemoteRuntimePtyRecoveriesNowMock } = vi.hoisted(() => ({ + retryAllRemoteRuntimePtyRecoveriesNowMock: vi.fn() +})) + +vi.mock('@/components/terminal-pane/remote-runtime-pty-recovery-state', () => ({ + retryAllRemoteRuntimePtyRecoveriesNow: retryAllRemoteRuntimePtyRecoveriesNowMock +})) + +function authority(): DirectSshAuthority { + return { + targetId: 'direct-target', + providerEpoch: 'direct-epoch' as SshProviderEpoch, + connectionGeneration: 7 + } +} + +describe('runtime and direct SSH wake isolation', () => { + const retryConnectionsNow = vi.fn(() => Promise.resolve()) + const resumeCallbacks = new Set<() => void>() + const onSystemResumed = vi.fn((callback: () => void) => { + resumeCallbacks.add(callback) + return () => resumeCallbacks.delete(callback) + }) + + beforeEach(() => { + retryConnectionsNow.mockClear() + retryAllRemoteRuntimePtyRecoveriesNowMock.mockClear() + onSystemResumed.mockClear() + resumeCallbacks.clear() + ;(window as unknown as { api: unknown }).api = { + runtimeEnvironments: { retryConnectionsNow }, + ui: { onSystemResumed } + } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + it('advances both runtime backoffs and one exact direct wake without rebumping a healthy pane', () => { + const currentAuthority = authority() + const directWorktreeId = 'repo-direct::/work/direct' + const runtimeWorktreeId = 'repo-runtime::/work/runtime' + const directPtyId = 'ssh:direct-target@@pty-live' + const store = createTestStore() + store.setState({ + repos: [ + { + id: 'repo-direct', + path: '/work/direct', + displayName: 'direct', + badgeColor: '#000', + addedAt: 1, + connectionId: 'direct-target', + executionHostId: 'ssh:direct-target' + }, + { + id: 'repo-runtime', + path: '/work/runtime', + displayName: 'runtime', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + ], + worktreesByRepo: { + 'repo-direct': [ + makeWorktree({ + id: directWorktreeId, + repoId: 'repo-direct', + path: '/work/direct', + hostId: 'ssh:direct-target' + }) + ], + 'repo-runtime': [ + makeWorktree({ + id: runtimeWorktreeId, + repoId: 'repo-runtime', + path: '/work/runtime', + hostId: 'ssh:runtime-owned-target', + runtimeOwnerEnvironmentId: 'env-1' + }) + ] + }, + tabsByWorktree: { + [directWorktreeId]: [ + makeTab({ + id: 'tab-direct', + worktreeId: directWorktreeId, + ptyId: directPtyId, + generation: 0 + }) + ], + [runtimeWorktreeId]: [ + makeTab({ id: 'tab-runtime', worktreeId: runtimeWorktreeId, ptyId: null }) + ] + }, + ptyIdsByTabId: { 'tab-direct': [directPtyId], 'tab-runtime': [] }, + directSshLivePtyBindingByTabId: { + 'tab-direct': { + attemptId: 'direct-attempt' as never, + authority: currentAuthority, + tabGeneration: 0, + ptyId: directPtyId + } + }, + sshConnectionStates: new Map([ + [ + 'direct-target', + { + targetId: 'direct-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: currentAuthority.providerEpoch, + connectionGeneration: currentAuthority.connectionGeneration + } + ] + ]), + sshStateByEnvironment: new Map([ + [ + 'env-1', + { + connectionStates: new Map([ + [ + 'runtime-owned-target', + { + targetId: 'runtime-owned-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'runtime-epoch' as SshProviderEpoch, + connectionGeneration: 3 + } + ] + ]), + targetLabels: new Map([['runtime-owned-target', 'runtime target']]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]) + }) + const wakePreparation = vi.fn() + const correctedCounts: number[] = [] + const unregisterDirectWake = registerDirectSshWakeRouting({ + getConnectionStates: () => store.getState().sshConnectionStates, + wakeAuthority: (nextAuthority) => { + correctedCounts.push(store.getState().retryDirectSshTargetPanes(nextAuthority, 1_000)) + wakePreparation(nextAuthority) + }, + onSystemResumed + }) + const { unmount } = renderHook(() => useRemoteRuntimeRecoveryTriggers()) + + window.dispatchEvent(new Event('online')) + + expect(retryConnectionsNow).toHaveBeenCalledTimes(1) + expect(retryAllRemoteRuntimePtyRecoveriesNowMock).toHaveBeenCalledTimes(1) + expect(wakePreparation).toHaveBeenCalledOnce() + expect(wakePreparation).toHaveBeenCalledWith(currentAuthority) + expect(correctedCounts).toEqual([0]) + expect(store.getState().tabsByWorktree[directWorktreeId][0]).toMatchObject({ + generation: 0, + ptyId: directPtyId + }) + expect(store.getState().tabsByWorktree[runtimeWorktreeId][0].generation ?? 0).toBe(0) + expect(store.getState().directSshPaneRetryByTabId).toEqual({}) + + unregisterDirectWake() + unmount() + window.dispatchEvent(new Event('online')) + expect(retryConnectionsNow).toHaveBeenCalledTimes(1) + expect(wakePreparation).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts new file mode 100644 index 00000000000..dbf7eb951b9 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { + directSshAuthorityFromConnectionState, + registerDirectSshWakeRouting, + routeDirectSshConnectedState +} from './direct-ssh-state-routing' + +function authority(epoch = 'epoch-a'): DirectSshAuthority { + return { + targetId: 'target-a', + providerEpoch: epoch as SshProviderEpoch, + connectionGeneration: 1 + } +} + +function harness() { + return { + coordinatorRoutingEnabled: true, + coordinator: { + requestReconnect: vi.fn(async () => ({ status: 'complete' })), + correctUnboundTerminals: vi.fn(() => 0), + replaceAuthority: vi.fn() + }, + invalidateStaleTerminalBindings: vi.fn(() => 0), + retryTargetPanes: vi.fn(() => 0), + prepareAndSync: vi.fn(), + rememberReconnectAuthority: vi.fn() + } +} + +describe('routeDirectSshConnectedState', () => { + it('keeps initial hydration preparation-only', () => { + const deps = harness() + const current = authority() + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: null, + origin: 'initial-hydration' + }) + ).toBe('initial-hydration') + expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'initial-hydration') + expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() + expect(deps.coordinator.correctUnboundTerminals).not.toHaveBeenCalled() + }) + + it('requests reconnect only when the complete pushed authority changes', () => { + const deps = harness() + const previous = authority('epoch-old') + const current = authority('epoch-new') + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: previous, + origin: 'push' + }) + ).toBe('changed-authority') + expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) + expect(deps.coordinator.requestReconnect).toHaveBeenCalledWith(current) + expect(deps.coordinator.replaceAuthority).not.toHaveBeenCalled() + expect(deps.prepareAndSync).not.toHaveBeenCalled() + }) + + it('runs the disabled fallback terminal actions synchronously before bounded preparation', () => { + const order: string[] = [] + const deps = harness() + deps.coordinatorRoutingEnabled = false + deps.coordinator.replaceAuthority.mockImplementation(() => order.push('replace')) + deps.invalidateStaleTerminalBindings.mockImplementation(() => { + order.push('invalidate') + return 1 + }) + deps.retryTargetPanes.mockImplementation(() => { + order.push('retry') + return 1 + }) + deps.prepareAndSync.mockImplementation(() => { + order.push('prepare') + return new Promise(() => {}) + }) + const previous = authority('epoch-old') + const current = authority('epoch-new') + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: previous, + origin: 'push' + }) + ).toBe('changed-authority-fallback') + + expect(order).toEqual(['replace', 'invalidate', 'retry', 'prepare']) + expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() + expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'reconnect', { + authorityAlreadyReplaced: true + }) + expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) + }) + + it('routes an exact-equal push through correction and fresh preparation', () => { + const deps = harness() + const current = authority() + + expect( + routeDirectSshConnectedState(deps, { + authority: current, + previousAuthority: { ...current }, + origin: 'push' + }) + ).toBe('same-authority-wake') + expect(deps.coordinator.correctUnboundTerminals).toHaveBeenCalledWith(current, 'wake-refresh') + expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'wake-refresh') + expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() + }) +}) + +describe('registerDirectSshWakeRouting', () => { + it('wakes only complete connected direct authorities and removes both listeners', () => { + const wakeAuthority = vi.fn() + let resume: (() => void) | undefined + const unsubscribeSystemResumed = vi.fn() + const stop = registerDirectSshWakeRouting({ + getConnectionStates: () => + new Map([ + [ + 'target-a', + { + ...authority(), + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + ], + [ + 'target-partial', + { + targetId: 'target-partial', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial' as SshProviderEpoch + } + ], + [ + 'target-disconnected', + { + ...authority('disconnected'), + targetId: 'target-disconnected', + status: 'disconnected' as const, + error: null, + reconnectAttempt: 0 + } + ] + ]), + wakeAuthority, + onSystemResumed: (callback) => { + resume = callback + return unsubscribeSystemResumed + } + }) + + window.dispatchEvent(new Event('online')) + resume?.() + + expect(wakeAuthority).toHaveBeenCalledTimes(2) + expect(wakeAuthority).toHaveBeenNthCalledWith(1, authority()) + expect(wakeAuthority).toHaveBeenNthCalledWith(2, authority()) + + stop() + stop() + window.dispatchEvent(new Event('online')) + resume?.() + expect(unsubscribeSystemResumed).toHaveBeenCalledOnce() + expect(wakeAuthority).toHaveBeenCalledTimes(2) + }) + + it('rejects mismatched keys and malformed partial authority', () => { + expect( + directSshAuthorityFromConnectionState('other-target', { + ...authority(), + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + ).toBeNull() + expect( + directSshAuthorityFromConnectionState('target-a', { + targetId: 'target-a', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.ts b/src/renderer/src/hooks/direct-ssh-state-routing.ts new file mode 100644 index 00000000000..7b3f263a5a7 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-state-routing.ts @@ -0,0 +1,110 @@ +import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' + +export type DirectSshConnectedStateOrigin = 'push' | 'initial-hydration' + +export type DirectSshConnectedStateRoutingDeps = { + coordinator: { + requestReconnect: (authority: DirectSshAuthority) => Promise + correctUnboundTerminals: (authority: DirectSshAuthority, reason: 'wake-refresh') => number + replaceAuthority: (authority: DirectSshAuthority) => void + } + coordinatorRoutingEnabled: boolean + invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number + retryTargetPanes: (authority: DirectSshAuthority) => number + prepareAndSync: ( + authority: DirectSshAuthority, + reason: 'reconnect' | 'initial-hydration' | 'wake-refresh', + options?: { authorityAlreadyReplaced?: boolean } + ) => void | Promise + rememberReconnectAuthority: (authority: DirectSshAuthority | null) => void +} + +export type DirectSshConnectedStateRoute = + | 'initial-hydration' + | 'changed-authority' + | 'changed-authority-fallback' + | 'same-authority-wake' + +export function routeDirectSshConnectedState( + deps: DirectSshConnectedStateRoutingDeps, + input: { + authority: DirectSshAuthority + previousAuthority: DirectSshAuthority | null + origin: DirectSshConnectedStateOrigin + } +): DirectSshConnectedStateRoute { + const { authority } = input + if (input.origin === 'initial-hydration') { + deps.rememberReconnectAuthority(null) + void deps.prepareAndSync(authority, 'initial-hydration') + return 'initial-hydration' + } + if (!directSshAuthoritiesEqual(input.previousAuthority, authority)) { + deps.rememberReconnectAuthority(authority) + if (deps.coordinatorRoutingEnabled) { + void deps.coordinator.requestReconnect(authority) + return 'changed-authority' + } + deps.coordinator.replaceAuthority(authority) + deps.invalidateStaleTerminalBindings(authority) + deps.retryTargetPanes(authority) + void deps.prepareAndSync(authority, 'reconnect', { authorityAlreadyReplaced: true }) + return 'changed-authority-fallback' + } + deps.coordinator.correctUnboundTerminals(authority, 'wake-refresh') + void deps.prepareAndSync(authority, 'wake-refresh') + return 'same-authority-wake' +} + +export function directSshAuthorityFromConnectionState( + targetId: string, + state: SshConnectionState +): DirectSshAuthority | null { + if ( + state.targetId !== targetId || + state.status !== 'connected' || + !state.providerEpoch || + state.connectionGeneration === undefined + ) { + return null + } + return { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } +} + +export function registerDirectSshWakeRouting(deps: { + getConnectionStates: () => Iterable + wakeAuthority: (authority: DirectSshAuthority) => void + onSystemResumed?: (callback: () => void) => () => void +}): () => void { + let stopped = false + const wake = (): void => { + if (stopped) { + return + } + for (const [targetId, state] of deps.getConnectionStates()) { + const authority = directSshAuthorityFromConnectionState(targetId, state) + if (authority) { + deps.wakeAuthority(authority) + } + } + } + if (typeof window.addEventListener === 'function') { + window.addEventListener('online', wake) + } + const unsubscribeSystemResumed = deps.onSystemResumed?.(wake) + return () => { + if (stopped) { + return + } + stopped = true + if (typeof window.removeEventListener === 'function') { + window.removeEventListener('online', wake) + } + unsubscribeSystemResumed?.() + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts new file mode 100644 index 00000000000..b7c75454220 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts @@ -0,0 +1,16 @@ +import type { + DirectSshWorktreeRefreshAttempt, + DirectSshWorktreeRefreshReleaseReason +} from './direct-ssh-worktree-refresh-scheduler-types' + +export function cancelDirectSshWorktreeRefreshAttempt( + attempt: DirectSshWorktreeRefreshAttempt, + reason: DirectSshWorktreeRefreshReleaseReason +): boolean { + try { + const outcome = attempt.cancel(reason) + return outcome !== false && outcome !== 'retained' && outcome !== 'already-settled' + } catch { + return true + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts new file mode 100644 index 00000000000..52064521dd7 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts @@ -0,0 +1,95 @@ +import type { + DirectSshWorktreeRefreshLogicalTask, + DirectSshWorktreeRefreshMetrics, + DirectSshWorktreeRefreshSchedulerSnapshot +} from './direct-ssh-worktree-refresh-scheduler-types' + +export function createDirectSshWorktreeRefreshMetrics(): DirectSshWorktreeRefreshMetrics { + return { + queueWaitDurationsMs: [], + providerExecutionDurationsMs: [], + timeoutRetryCount: 0, + locallySettledWaiterCount: 0, + cancelDebtCount: 0, + replacementAdmissionDelayedCount: 0, + overlappingJoinCount: 0, + peakLocallyUnsettled: 0, + estimatedLateWorkAllowanceCount: 0 + } +} + +export function adjustDirectSshAuthorityUnsettled( + unsettledByAuthority: Map, + authorityId: string, + delta: number +): void { + const next = (unsettledByAuthority.get(authorityId) ?? 0) + delta + if (next > 0) { + unsettledByAuthority.set(authorityId, next) + } else { + unsettledByAuthority.delete(authorityId) + } +} + +export function copyDirectSshWorktreeRefreshMetrics( + metrics: DirectSshWorktreeRefreshMetrics +): DirectSshWorktreeRefreshMetrics { + return { + ...metrics, + queueWaitDurationsMs: [...metrics.queueWaitDurationsMs], + providerExecutionDurationsMs: [...metrics.providerExecutionDurationsMs] + } +} + +export function recordDirectSshCancelDebt( + metrics: DirectSshWorktreeRefreshMetrics, + lateWorkLimit: number +): void { + metrics.cancelDebtCount++ + metrics.estimatedLateWorkAllowanceCount = Math.min( + lateWorkLimit, + metrics.estimatedLateWorkAllowanceCount + 1 + ) +} + +export function recordDirectSshQueueAdmission( + metrics: DirectSshWorktreeRefreshMetrics, + queueWaitDurationMs: number, + locallyUnsettled: number +): void { + metrics.queueWaitDurationsMs = [...metrics.queueWaitDurationsMs, queueWaitDurationMs] + metrics.peakLocallyUnsettled = Math.max(metrics.peakLocallyUnsettled, locallyUnsettled) +} + +export function recordDirectSshProviderExecution( + metrics: DirectSshWorktreeRefreshMetrics, + providerExecutionDurationMs: number +): void { + metrics.providerExecutionDurationsMs = [ + ...metrics.providerExecutionDurationsMs, + providerExecutionDurationMs + ] +} + +export function directSshWorktreeRefreshSchedulerSnapshot( + tasks: ReadonlyMap, + locallyUnsettled: number, + cancelDebtByAuthority: ReadonlyMap +): DirectSshWorktreeRefreshSchedulerSnapshot { + let queued = 0 + let retrying = 0 + let waiters = 0 + for (const task of tasks.values()) { + queued += task.state === 'queued' ? 1 : 0 + retrying += task.state === 'retrying' ? 1 : 0 + waiters += task.waiters.size + } + return { + locallyUnsettled, + queued, + retrying, + logicalTasks: tasks.size, + waiters, + cancelDebtByAuthority: new Map(cancelDebtByAuthority) + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts new file mode 100644 index 00000000000..1240304f8cd --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts @@ -0,0 +1,128 @@ +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId, + SshExecutionHostId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DetectedWorktreeRefreshReleaseOutcome, + WaiterLeaseId +} from '../store/slices/detected-worktree-refresh-leases' + +export type { WaiterLeaseId } from '../store/slices/detected-worktree-refresh-leases' + +export type DirectSshWorktreeRefreshAuthorityRequirement = 'required' | 'allow-metadata-fallback' + +export type DirectSshWorktreeRefreshKey = DirectSshAuthority & { + repoId: string + executionHostId: SshExecutionHostId + catalogRevision: number + authorityRequirement: DirectSshWorktreeRefreshAuthorityRequirement +} + +export type DirectSshWorktreeRefreshTerminalStatus = + | 'complete' + | 'non-authoritative' + | 'timed-out' + | 'cancel-budget-exhausted' + | 'canceled' + | 'stale' + | 'rejected' + +export type DirectSshWorktreeRefreshOutcome = { + status: DirectSshWorktreeRefreshTerminalStatus + providerRequestId?: ProviderRequestId + providerResult?: HostQualifiedDetectedWorktreeResult + metrics?: DirectSshWorktreeRefreshMetrics +} + +export type DirectSshWorktreeRefreshMetrics = { + queueWaitDurationsMs: readonly number[] + providerExecutionDurationsMs: readonly number[] + timeoutRetryCount: number + locallySettledWaiterCount: number + cancelDebtCount: number + replacementAdmissionDelayedCount: number + overlappingJoinCount: number + peakLocallyUnsettled: number + estimatedLateWorkAllowanceCount: number +} + +export type DirectSshWorktreeRefreshReleaseReason = 'superseded' | 'invalidated' | 'stopped' + +export type DirectSshWorktreeRefreshLease = { + waiterLeaseId: WaiterLeaseId + result: Promise + release: (reason: DirectSshWorktreeRefreshReleaseReason) => void +} + +export type DirectSshWorktreeRefreshAttempt = { + providerRequestId: ProviderRequestId + result: Promise + cancel: ( + reason: DirectSshWorktreeRefreshReleaseReason + ) => boolean | void | DetectedWorktreeRefreshReleaseOutcome +} + +export type DirectSshWorktreeRefreshSchedulerDeps = { + startAttempt: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshAttempt + createWaiterLeaseId?: () => WaiterLeaseId + onUnexpectedError?: (error: unknown) => void + now?: () => number +} + +export type DirectSshWorktreeRefreshSchedulerSnapshot = { + locallyUnsettled: number + queued: number + retrying: number + logicalTasks: number + waiters: number + cancelDebtByAuthority: ReadonlyMap +} + +export type DirectSshWorktreeRefreshScheduler = { + request: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshLease + invalidateAuthority: (authority: DirectSshAuthority) => void + invalidateTarget: (targetId: string) => void + disposeProvider: (authority: DirectSshAuthority) => void + getSnapshot: () => DirectSshWorktreeRefreshSchedulerSnapshot + stop: () => void +} + +export type DirectSshWorktreeRefreshWaiter = { + resolve: (outcome: DirectSshWorktreeRefreshOutcome) => void +} + +export type DirectSshWorktreeRefreshLogicalTask = { + key: DirectSshWorktreeRefreshKey + keyId: string + authorityId: string + state: 'queued' | 'running' | 'retrying' | 'terminal' + attemptCount: number + attempt: DirectSshWorktreeRefreshAttempt | null + attemptCanceled: boolean + attemptStartedAt: number | null + queuedAt: number + metrics: DirectSshWorktreeRefreshMetrics + waiters: Map +} + +export function directSshProviderAuthorityKey(authority: DirectSshAuthority): string { + return JSON.stringify([ + authority.targetId, + authority.providerEpoch, + authority.connectionGeneration + ]) +} + +export function directSshWorktreeRefreshKey(key: DirectSshWorktreeRefreshKey): string { + return JSON.stringify([ + key.targetId, + key.repoId, + key.executionHostId, + key.providerEpoch, + key.connectionGeneration, + key.catalogRevision, + key.authorityRequirement + ]) +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts new file mode 100644 index 00000000000..800938a1a67 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { + createDirectSshWorktreeRefreshScheduler, + DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY, + type DirectSshWorktreeRefreshAttempt, + type DirectSshWorktreeRefreshKey, + type DirectSshWorktreeRefreshReleaseReason +} from './direct-ssh-worktree-refresh-scheduler' + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +type ControlledAttempt = DirectSshWorktreeRefreshAttempt & { + key: DirectSshWorktreeRefreshKey + deferred: Deferred + cancel: ReturnType> +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function epoch(value: string): SshProviderEpoch { + return value as SshProviderEpoch +} + +function requestId(value: string): ProviderRequestId { + return value as ProviderRequestId +} + +function key( + targetId: string, + repoId: string, + overrides: Partial = {} +): DirectSshWorktreeRefreshKey { + return { + targetId, + repoId, + executionHostId: `ssh:${targetId}`, + providerEpoch: epoch(`epoch-${targetId}`), + connectionGeneration: 1, + catalogRevision: 1, + authorityRequirement: 'required', + ...overrides + } +} + +function terminalResult( + attempt: ControlledAttempt, + status: 'timed-out' | 'canceled' | 'stale' | 'rejected' +): HostQualifiedDetectedWorktreeResult { + return { + status, + providerRequestId: attempt.providerRequestId, + executionHostId: attempt.key.executionHostId + } +} + +function completeResult( + attempt: ControlledAttempt, + status: 'complete' | 'non-authoritative' = 'complete' +): HostQualifiedDetectedWorktreeResult { + return { + status, + providerRequestId: attempt.providerRequestId, + repoId: attempt.key.repoId, + authority: { + kind: 'direct-ssh', + executionHostId: attempt.key.executionHostId, + targetId: attempt.key.targetId, + providerEpoch: attempt.key.providerEpoch, + connectionGeneration: attempt.key.connectionGeneration + }, + result: { + repoId: attempt.key.repoId, + authoritative: status === 'complete', + source: status === 'complete' ? 'git' : 'metadata-fallback', + worktrees: [] + } + } +} + +function createHarness(cancelOutcome?: 'cancel-failed', now?: () => number) { + const attempts: ControlledAttempt[] = [] + const onUnexpectedError = vi.fn() + const startAttempt = vi.fn((attemptKey: DirectSshWorktreeRefreshKey) => { + const providerDeferred = deferred() + const waiterDeferred = deferred() + const providerRequestId = requestId(`provider-${attempts.length + 1}`) + const attempt: ControlledAttempt = { + key: attemptKey, + providerRequestId, + result: waiterDeferred.promise, + deferred: providerDeferred, + cancel: vi.fn((_reason: DirectSshWorktreeRefreshReleaseReason) => { + waiterDeferred.resolve({ + providerRequestId, + executionHostId: attemptKey.executionHostId, + status: 'canceled' + }) + return cancelOutcome + }) + } + void providerDeferred.promise.then(waiterDeferred.resolve, waiterDeferred.reject) + attempts.push(attempt) + return attempt + }) + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt, + onUnexpectedError, + now + }) + return { scheduler, attempts, startAttempt, onUnexpectedError } +} + +async function flushAttempt(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('createDirectSshWorktreeRefreshScheduler', () => { + it('admits an idle singleton immediately and cleans it up on completion', async () => { + const { scheduler, attempts, startAttempt } = createHarness() + const lease = scheduler.request(key('target-a', 'repo-a')) + + expect(startAttempt).toHaveBeenCalledTimes(1) + expect(scheduler.getSnapshot().locallyUnsettled).toBe(1) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await expect(lease.result).resolves.toMatchObject({ status: 'complete' }) + await flushAttempt() + expect(scheduler.getSnapshot()).toMatchObject({ + locallyUnsettled: 0, + logicalTasks: 0, + waiters: 0 + }) + }) + + it('never admits more than five locally unsettled attempts', async () => { + const { scheduler, attempts } = createHarness() + const leases = Array.from({ length: 6 }, (_, index) => + scheduler.request(key('target-a', `repo-${index}`)) + ) + + expect(attempts).toHaveLength(DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) + expect(scheduler.getSnapshot().queued).toBe(1) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await leases[0].result + await flushAttempt() + expect(attempts).toHaveLength(6) + expect(scheduler.getSnapshot().locallyUnsettled).toBe(5) + + scheduler.stop() + }) + + it('round-robins target lanes after at most one earlier lane admission', async () => { + const { scheduler, attempts } = createHarness() + const leases = Array.from({ length: 7 }, (_, index) => + scheduler.request(key('target-a', `repo-a-${index}`)) + ) + const targetB = scheduler.request(key('target-b', 'repo-b')) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await leases[0].result + await flushAttempt() + expect(attempts[5].key.targetId).toBe('target-a') + + attempts[1].deferred.resolve(completeResult(attempts[1])) + await leases[1].result + await flushAttempt() + expect(attempts[6].key.targetId).toBe('target-b') + + scheduler.stop() + await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) + }) + + it('joins only exact keys and separates authority, revision, and requirement', () => { + const { scheduler, attempts } = createHarness() + const base = key('target-a', 'repo') + const first = scheduler.request(base) + const joined = scheduler.request({ ...base }) + scheduler.request({ ...base, providerEpoch: epoch('epoch-next') }) + scheduler.request({ ...base, connectionGeneration: 2 }) + scheduler.request({ ...base, catalogRevision: 2 }) + scheduler.request({ ...base, authorityRequirement: 'allow-metadata-fallback' }) + + expect(first.waiterLeaseId).not.toBe(joined.waiterLeaseId) + expect(attempts).toHaveLength(5) + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 5, waiters: 6 }) + scheduler.stop() + }) + + it('keeps the logical barrier pending through one timeout and uses a fresh request ID', async () => { + const { scheduler, attempts } = createHarness() + const lease = scheduler.request(key('target-a', 'repo')) + let settled = false + void lease.result.then(() => { + settled = true + }) + + attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) + await flushAttempt() + + expect(settled).toBe(false) + expect(attempts).toHaveLength(2) + expect(attempts[1].providerRequestId).not.toBe(attempts[0].providerRequestId) + + attempts[1].deferred.resolve(completeResult(attempts[1])) + await expect(lease.result).resolves.toMatchObject({ + status: 'complete', + providerRequestId: attempts[1].providerRequestId + }) + }) + + it('requeues a timed-out retry at its target lane tail', async () => { + const { scheduler, attempts } = createHarness() + const retrying = scheduler.request(key('target-a', 'retrying')) + const blockers = Array.from({ length: 4 }, (_, index) => + scheduler.request(key('target-a', `blocker-${index}`)) + ) + const targetB = scheduler.request(key('target-b', 'target-b')) + + attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) + await flushAttempt() + expect(attempts[5].key.targetId).toBe('target-b') + + attempts[1].deferred.resolve(completeResult(attempts[1])) + await blockers[0].result + await flushAttempt() + expect(attempts[6].key.repoId).toBe('retrying') + + attempts[6].deferred.resolve(completeResult(attempts[6])) + await expect(retrying.result).resolves.toMatchObject({ status: 'complete' }) + scheduler.stop() + await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) + }) + + it('makes a second timeout terminal', async () => { + const { scheduler, attempts } = createHarness() + const lease = scheduler.request(key('target-a', 'repo')) + + attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) + await flushAttempt() + attempts[1].deferred.resolve(terminalResult(attempts[1], 'timed-out')) + + await expect(lease.result).resolves.toMatchObject({ + status: 'timed-out', + providerRequestId: attempts[1].providerRequestId + }) + }) + + it('invalidates queued and current work without reporting cancellation errors', async () => { + const { scheduler, attempts, onUnexpectedError } = createHarness() + const leases = Array.from({ length: 6 }, (_, index) => + scheduler.request(key('target-a', `repo-${index}`)) + ) + + scheduler.invalidateTarget('target-a') + await expect(Promise.all(leases.map((lease) => lease.result))).resolves.toEqual( + Array.from({ length: 6 }, () => expect.objectContaining({ status: 'stale' })) + ) + expect(attempts.map((attempt) => attempt.cancel.mock.calls.length)).toEqual([1, 1, 1, 1, 1]) + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) + + for (const attempt of attempts) { + attempt.deferred.reject(new Error('expected provider cancellation')) + } + await flushAttempt() + expect(onUnexpectedError).not.toHaveBeenCalled() + expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) + }) + + it('bounds five failed cancellations to two replacements while old calls remain pending', async () => { + const { scheduler, attempts } = createHarness('cancel-failed') + const authority: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: epoch('epoch-target-a'), + connectionGeneration: 1 + } + const initial = Array.from({ length: 5 }, (_, index) => + scheduler.request(key('target-a', `old-${index}`)) + ) + scheduler.invalidateAuthority(authority) + await Promise.all(initial.map((lease) => lease.result)) + await flushAttempt() + expect(scheduler.getSnapshot()).toMatchObject({ + locallyUnsettled: 0, + queued: 0, + logicalTasks: 0 + }) + + const replacements = Array.from({ length: 3 }, (_, index) => + scheduler.request(key('target-a', `replacement-${index}`)) + ) + expect(attempts).toHaveLength(7) + expect(scheduler.getSnapshot().locallyUnsettled).toBe(2) + await expect(replacements[2].result).resolves.toMatchObject({ + status: 'cancel-budget-exhausted' + }) + + attempts[5].deferred.resolve(completeResult(attempts[5])) + attempts[6].deferred.resolve(completeResult(attempts[6])) + await expect( + Promise.all(replacements.slice(0, 2).map((lease) => lease.result)) + ).resolves.toEqual( + Array.from({ length: 2 }, () => expect.objectContaining({ status: 'complete' })) + ) + await flushAttempt() + expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) + + scheduler.disposeProvider(authority) + for (const attempt of attempts.slice(0, 5)) { + attempt.deferred.reject(new Error('late canceled provider')) + } + await flushAttempt() + expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) + + const afterDisposal = scheduler.request(key('target-a', 'after-disposal')) + expect(attempts).toHaveLength(8) + attempts[7].deferred.resolve(completeResult(attempts[7])) + await expect(afterDisposal.result).resolves.toMatchObject({ status: 'complete' }) + }) + + it('settles one released waiter without canceling a joined provider', async () => { + const { scheduler, attempts } = createHarness() + const refreshKey = key('target-a', 'repo') + const released = scheduler.request(refreshKey) + const retained = scheduler.request(refreshKey) + + released.release('superseded') + await expect(released.result).resolves.toMatchObject({ status: 'canceled' }) + expect(attempts[0].cancel).not.toHaveBeenCalled() + expect(scheduler.getSnapshot().waiters).toBe(1) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await expect(retained.result).resolves.toMatchObject({ status: 'complete' }) + released.release('stopped') + await flushAttempt() + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) + }) + + it('does not charge cancel debt when a shared provider invocation stays active', async () => { + const { scheduler, attempts } = createHarness() + const lease = scheduler.request(key('target-a', 'repo')) + attempts[0].cancel.mockReturnValue(false) + + lease.release('superseded') + + await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) + expect(attempts[0].cancel).toHaveBeenCalledOnce() + expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) + + attempts[0].deferred.resolve(completeResult(attempts[0])) + await flushAttempt() + }) + + it('stops current and queued waiters and rejects later requests without starting', async () => { + const { scheduler, attempts, startAttempt } = createHarness() + const current = scheduler.request(key('target-a', 'current')) + scheduler.stop() + + await expect(current.result).resolves.toMatchObject({ status: 'canceled' }) + expect(attempts[0].cancel).toHaveBeenCalledOnce() + + const afterStop = scheduler.request(key('target-a', 'after-stop')) + await expect(afterStop.result).resolves.toMatchObject({ status: 'canceled' }) + expect(startAttempt).toHaveBeenCalledOnce() + expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) + + attempts[0].deferred.reject(new Error('stopped')) + await flushAttempt() + }) + + it('surfaces non-authoritative and rejected terminal outcomes', async () => { + const { scheduler, attempts } = createHarness() + const metadata = scheduler.request(key('target-a', 'metadata')) + const rejected = scheduler.request(key('target-a', 'rejected')) + + attempts[0].deferred.resolve(completeResult(attempts[0], 'non-authoritative')) + attempts[1].deferred.resolve(terminalResult(attempts[1], 'rejected')) + + await expect(metadata.result).resolves.toMatchObject({ status: 'non-authoritative' }) + await expect(rejected.result).resolves.toMatchObject({ status: 'rejected' }) + }) + + it('reports queue wait, provider execution, retry, joins, and scoped peak metrics', async () => { + let now = 0 + const { scheduler, attempts } = createHarness(undefined, () => now) + const leases = Array.from({ length: 6 }, (_, index) => + scheduler.request(key('target-a', `repo-${index}`)) + ) + scheduler.request(key('target-a', 'repo-5')) + + now = 25 + attempts[0].deferred.resolve(completeResult(attempts[0])) + await leases[0].result + await flushAttempt() + expect(attempts).toHaveLength(6) + + now = 100 + attempts[5].deferred.resolve(terminalResult(attempts[5], 'timed-out')) + await flushAttempt() + now = 140 + attempts[6].deferred.resolve(completeResult(attempts[6])) + + await expect(leases[5].result).resolves.toMatchObject({ + status: 'complete', + metrics: { + queueWaitDurationsMs: [25, 0], + providerExecutionDurationsMs: [75, 40], + timeoutRetryCount: 1, + cancelDebtCount: 1, + overlappingJoinCount: 1, + peakLocallyUnsettled: 5 + } + }) + scheduler.stop() + }) +}) diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts new file mode 100644 index 00000000000..2e95404f1b2 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts @@ -0,0 +1,316 @@ +import type { + HostQualifiedDetectedWorktreeResult, + ProviderRequestId +} from '../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import type { + DirectSshWorktreeRefreshAttempt, + DirectSshWorktreeRefreshKey, + DirectSshWorktreeRefreshLease, + DirectSshWorktreeRefreshLogicalTask as LogicalTask, + DirectSshWorktreeRefreshOutcome, + DirectSshWorktreeRefreshReleaseReason, + DirectSshWorktreeRefreshScheduler, + DirectSshWorktreeRefreshSchedulerDeps, + WaiterLeaseId +} from './direct-ssh-worktree-refresh-scheduler-types' +import { cancelDirectSshWorktreeRefreshAttempt } from './direct-ssh-worktree-refresh-scheduler-cancellation' +import { + adjustDirectSshAuthorityUnsettled, + copyDirectSshWorktreeRefreshMetrics, + createDirectSshWorktreeRefreshMetrics, + directSshWorktreeRefreshSchedulerSnapshot, + recordDirectSshCancelDebt, + recordDirectSshProviderExecution, + recordDirectSshQueueAdmission +} from './direct-ssh-worktree-refresh-scheduler-metrics' +import { DirectSshWorktreeRefreshTargetQueue } from './direct-ssh-worktree-refresh-target-queue' +import { + directSshProviderAuthorityKey, + directSshWorktreeRefreshKey +} from './direct-ssh-worktree-refresh-scheduler-types' +export type * from './direct-ssh-worktree-refresh-scheduler-types' + +export const DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY = 5 +export const DIRECT_SSH_PROVIDER_START_BUDGET = 7 + +export function createDirectSshWorktreeRefreshScheduler( + deps: DirectSshWorktreeRefreshSchedulerDeps +): DirectSshWorktreeRefreshScheduler { + const now = deps.now ?? Date.now + const tasksByKey = new Map() + const targetQueue = new DirectSshWorktreeRefreshTargetQueue() + const unsettledByAuthority = new Map() + const cancelDebtByAuthority = new Map() + let locallyUnsettled = 0 + let leaseSequence = 0 + let stopped = false + + const createLeaseId = (): WaiterLeaseId => + deps.createWaiterLeaseId?.() ?? (`direct-ssh-waiter-${++leaseSequence}` as WaiterLeaseId) + + const metricsFor = ( + task: LogicalTask, + locallySettledWaiterCount = task.metrics.locallySettledWaiterCount + ): DirectSshWorktreeRefreshOutcome['metrics'] => { + const metrics = copyDirectSshWorktreeRefreshMetrics(task.metrics) + metrics.locallySettledWaiterCount = locallySettledWaiterCount + return metrics + } + + const finishTask = (task: LogicalTask, outcome: DirectSshWorktreeRefreshOutcome): void => { + if (task.state === 'terminal') { + return + } + task.state = 'terminal' + task.attempt = null + tasksByKey.delete(task.keyId) + task.metrics.locallySettledWaiterCount += task.waiters.size + const settledOutcome = { ...outcome, metrics: metricsFor(task) } + for (const waiter of task.waiters.values()) { + waiter.resolve(settledOutcome) + } + task.waiters.clear() + } + + const addCancelDebt = (task: LogicalTask): void => { + recordDirectSshCancelDebt( + task.metrics, + DIRECT_SSH_PROVIDER_START_BUDGET - DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY + ) + cancelDebtByAuthority.set( + task.authorityId, + (cancelDebtByAuthority.get(task.authorityId) ?? 0) + 1 + ) + } + + const cancelAttempt = ( + task: LogicalTask, + reason: DirectSshWorktreeRefreshReleaseReason + ): void => { + if (!task.attempt || task.attemptCanceled) { + return + } + task.attemptCanceled = true + if (cancelDirectSshWorktreeRefreshAttempt(task.attempt, reason)) { + addCancelDebt(task) + } + } + + const canStart = (task: LogicalTask): boolean => { + const unsettled = unsettledByAuthority.get(task.authorityId) ?? 0 + const debt = cancelDebtByAuthority.get(task.authorityId) ?? 0 + return unsettled + debt + 1 <= DIRECT_SSH_PROVIDER_START_BUDGET + } + + const handleAttemptResult = ( + task: LogicalTask, + requestId: ProviderRequestId, + result: HostQualifiedDetectedWorktreeResult + ): void => { + if (task.state === 'terminal' || task.attempt?.providerRequestId !== requestId) { + return + } + if (result.providerRequestId !== requestId) { + finishTask(task, { status: 'rejected', providerRequestId: requestId }) + return + } + if (result.status === 'timed-out') { + addCancelDebt(task) + if (task.attemptCount === 1) { + task.metrics.timeoutRetryCount++ + task.attempt = null + task.attemptCanceled = true + targetQueue.enqueue(task, true, now()) + return + } + } + const status = + result.status === 'authority-unknown' || result.status === 'ambiguous-owner' + ? 'non-authoritative' + : result.status + finishTask(task, { + status, + providerRequestId: result.providerRequestId, + providerResult: result + }) + } + + function drain(): void { + while (!stopped && locallyUnsettled < DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) { + const task = targetQueue.takeNext() + if (!task) { + return + } + if (!canStart(task)) { + task.metrics.replacementAdmissionDelayedCount++ + finishTask(task, { status: 'cancel-budget-exhausted' }) + continue + } + task.state = 'running' + task.attemptCount++ + locallyUnsettled++ + recordDirectSshQueueAdmission( + task.metrics, + Math.max(0, now() - task.queuedAt), + locallyUnsettled + ) + adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, 1) + let attempt: DirectSshWorktreeRefreshAttempt + try { + attempt = deps.startAttempt(task.key) + task.attempt = attempt + task.attemptCanceled = false + task.attemptStartedAt = now() + } catch (error) { + locallyUnsettled-- + adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) + deps.onUnexpectedError?.(error) + finishTask(task, { status: 'rejected' }) + continue + } + void attempt.result + .then((result) => { + if (task.attemptStartedAt !== null) { + recordDirectSshProviderExecution( + task.metrics, + Math.max(0, now() - task.attemptStartedAt) + ) + task.attemptStartedAt = null + } + handleAttemptResult(task, attempt.providerRequestId, result) + }) + .catch((error) => { + if (task.attemptStartedAt !== null) { + recordDirectSshProviderExecution( + task.metrics, + Math.max(0, now() - task.attemptStartedAt) + ) + task.attemptStartedAt = null + } + if (task.state !== 'terminal' && !task.attemptCanceled) { + deps.onUnexpectedError?.(error) + finishTask(task, { + status: 'rejected', + providerRequestId: attempt.providerRequestId + }) + } + }) + .finally(() => { + locallyUnsettled-- + adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) + drain() + }) + } + } + + const releaseWaiter = ( + task: LogicalTask, + waiterLeaseId: WaiterLeaseId, + reason: DirectSshWorktreeRefreshReleaseReason + ): void => { + const waiter = task.waiters.get(waiterLeaseId) + if (!waiter) { + return + } + task.waiters.delete(waiterLeaseId) + task.metrics.locallySettledWaiterCount++ + waiter.resolve({ status: 'canceled', metrics: metricsFor(task) }) + if (task.waiters.size > 0) { + return + } + cancelAttempt(task, reason) + finishTask(task, { status: 'canceled' }) + } + + const request = (key: DirectSshWorktreeRefreshKey): DirectSshWorktreeRefreshLease => { + const keyId = directSshWorktreeRefreshKey(key) + let task = tasksByKey.get(keyId) + if (!task) { + const requestedAt = now() + task = { + key, + keyId, + authorityId: directSshProviderAuthorityKey(key), + state: 'queued', + attemptCount: 0, + attempt: null, + attemptCanceled: false, + attemptStartedAt: null, + queuedAt: requestedAt, + metrics: createDirectSshWorktreeRefreshMetrics(), + waiters: new Map() + } + tasksByKey.set(keyId, task) + targetQueue.enqueue(task, false, now()) + } else { + task.metrics.overlappingJoinCount++ + } + const waiterLeaseId = createLeaseId() + let resolve!: (outcome: DirectSshWorktreeRefreshOutcome) => void + const result = new Promise((settle) => { + resolve = settle + }) + task.waiters.set(waiterLeaseId, { resolve }) + if (stopped) { + releaseWaiter(task, waiterLeaseId, 'stopped') + targetQueue.clear() + } else { + drain() + } + return { + waiterLeaseId, + result, + release: (reason) => releaseWaiter(task!, waiterLeaseId, reason) + } + } + + const invalidateMatching = (predicate: (task: LogicalTask) => boolean): void => { + for (const task of tasksByKey.values()) { + if (!predicate(task)) { + continue + } + cancelAttempt(task, 'invalidated') + finishTask(task, { status: 'stale', providerRequestId: task.attempt?.providerRequestId }) + } + drain() + } + + const invalidateAuthority = (authority: DirectSshAuthority): void => { + const id = directSshProviderAuthorityKey(authority) + invalidateMatching((task) => task.authorityId === id) + } + + const invalidateTarget = (targetId: string): void => { + invalidateMatching((task) => task.key.targetId === targetId) + } + + const disposeProvider = (authority: DirectSshAuthority): void => { + invalidateAuthority(authority) + cancelDebtByAuthority.delete(directSshProviderAuthorityKey(authority)) + } + + const getSnapshot = () => + directSshWorktreeRefreshSchedulerSnapshot(tasksByKey, locallyUnsettled, cancelDebtByAuthority) + + const stop = (): void => { + if (stopped) { + return + } + stopped = true + for (const task of tasksByKey.values()) { + cancelAttempt(task, 'stopped') + finishTask(task, { status: 'canceled', providerRequestId: task.attempt?.providerRequestId }) + } + targetQueue.clear() + } + + return { + request, + invalidateAuthority, + invalidateTarget, + disposeProvider, + getSnapshot, + stop + } +} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts new file mode 100644 index 00000000000..4d549b4b437 --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts @@ -0,0 +1,44 @@ +import type { DirectSshWorktreeRefreshLogicalTask } from './direct-ssh-worktree-refresh-scheduler-types' + +export class DirectSshWorktreeRefreshTargetQueue { + private readonly queuedByTarget = new Map() + private readonly targetOrder: string[] = [] + + enqueue(task: DirectSshWorktreeRefreshLogicalTask, retrying: boolean, now: number): void { + task.state = retrying ? 'retrying' : 'queued' + task.queuedAt = now + const lane = this.queuedByTarget.get(task.key.targetId) + if (lane) { + lane.push(task) + return + } + this.queuedByTarget.set(task.key.targetId, [task]) + this.targetOrder.push(task.key.targetId) + } + + takeNext(): DirectSshWorktreeRefreshLogicalTask | null { + while (this.targetOrder.length > 0) { + const targetId = this.targetOrder.shift()! + const lane = this.queuedByTarget.get(targetId) + const task = lane?.shift() + if (!lane || !task) { + this.queuedByTarget.delete(targetId) + continue + } + if (lane.length > 0) { + this.targetOrder.push(targetId) + } else { + this.queuedByTarget.delete(targetId) + } + if (task.state !== 'terminal' && task.waiters.size > 0) { + return task + } + } + return null + } + + clear(): void { + this.queuedByTarget.clear() + this.targetOrder.length = 0 + } +} diff --git a/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts new file mode 100644 index 00000000000..9a22d982e5d --- /dev/null +++ b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + dismissMacosTccPromptNotice, + subscribeToMacosTccPromptNotice +} from './macos-tcc-prompt-notice-subscription' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('subscribeToMacosTccPromptNotice', () => { + it('contains synchronous and rejected dismissal failures', async () => { + const synchronousFailure = vi.fn(() => { + throw new Error('renderer closing') + }) + const rejectedFailure = vi.fn().mockRejectedValue(new Error('ipc unavailable')) + + await expect( + dismissMacosTccPromptNotice({ dismiss: synchronousFailure }) + ).resolves.toBeUndefined() + await expect(dismissMacosTccPromptNotice({ dismiss: rejectedFailure })).resolves.toBeUndefined() + }) + + it('delivers a threshold retained before the renderer subscribed', async () => { + const onNotice = vi.fn() + const acknowledgePending = vi.fn().mockResolvedValue(undefined) + const unsubscribe = subscribeToMacosTccPromptNotice( + { + acknowledgePending, + consumePending: vi.fn().mockResolvedValue({ claimId: 7, promptCount: 3 }), + onThreshold: vi.fn(() => vi.fn()) + }, + onNotice + ) + + await Promise.resolve() + + expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }) + expect(acknowledgePending).toHaveBeenCalledWith(7) + expect(onNotice.mock.invocationCallOrder[0]).toBeLessThan( + acknowledgePending.mock.invocationCallOrder[0] + ) + unsubscribe() + }) + + it('consumes concurrent mount and live signals only once', async () => { + const listenerState: { listener?: (payload: { promptCount: number }) => void } = {} + const acknowledgePending = vi.fn().mockResolvedValue(undefined) + const consumePending = vi + .fn() + .mockResolvedValueOnce({ claimId: 8, promptCount: 3 }) + .mockResolvedValue(null) + const onNotice = vi.fn() + const unsubscribe = subscribeToMacosTccPromptNotice( + { + acknowledgePending, + consumePending, + onThreshold: (listener) => { + listenerState.listener = listener + return vi.fn() + } + }, + onNotice + ) + + listenerState.listener?.({ promptCount: 3 }) + await Promise.resolve() + + expect(consumePending).toHaveBeenCalledTimes(2) + expect(onNotice).toHaveBeenCalledOnce() + expect(acknowledgePending).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('finishes an in-flight claim through StrictMode cleanup', async () => { + const pendingState: { + resolve?: (payload: { claimId: number; promptCount: number } | null) => void + } = {} + const onNotice = vi.fn() + const acknowledgePending = vi.fn().mockResolvedValue(undefined) + const unsubscribe = subscribeToMacosTccPromptNotice( + { + acknowledgePending, + consumePending: () => + new Promise((resolve) => { + pendingState.resolve = resolve + }) + }, + onNotice + ) + + unsubscribe() + pendingState.resolve?.({ claimId: 9, promptCount: 3 }) + await Promise.resolve() + + expect(onNotice).toHaveBeenCalledOnce() + expect(acknowledgePending).toHaveBeenCalledWith(9) + }) + + it('retries once after releasing a transient failed display', async () => { + const error = new Error('toast unavailable') + const acknowledgePending = vi.fn().mockResolvedValue(undefined) + const consumePending = vi + .fn() + .mockResolvedValueOnce({ claimId: 10, promptCount: 3 }) + .mockResolvedValueOnce({ claimId: 11, promptCount: 3 }) + const releasePending = vi.fn().mockResolvedValue(undefined) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const onNotice = vi.fn().mockImplementationOnce(() => { + throw error + }) + + subscribeToMacosTccPromptNotice( + { + acknowledgePending, + consumePending, + releasePending + }, + onNotice + ) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(consumePending).toHaveBeenCalledTimes(2) + expect(onNotice).toHaveBeenCalledTimes(2) + expect(releasePending).toHaveBeenCalledWith(10) + expect(acknowledgePending).toHaveBeenCalledWith(11) + expect(consoleError).toHaveBeenCalledWith('[macos-tcc-prompts] Failed to show notice:', error) + }) + + it('bounds persistent display failures and release rejection', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const consumePending = vi + .fn() + .mockResolvedValueOnce({ claimId: 20, promptCount: 3 }) + .mockResolvedValueOnce({ claimId: 21, promptCount: 3 }) + const releasePending = vi.fn().mockResolvedValue(undefined) + subscribeToMacosTccPromptNotice( + { + acknowledgePending: vi.fn(), + consumePending, + releasePending + }, + () => { + throw new Error('persistent failure') + } + ) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(consumePending).toHaveBeenCalledTimes(2) + expect(releasePending).toHaveBeenCalledTimes(2) + + const rejectedRelease = vi.fn().mockRejectedValue(new Error('ipc unavailable')) + const rejectedConsume = vi.fn().mockResolvedValue({ claimId: 22, promptCount: 3 }) + subscribeToMacosTccPromptNotice( + { + consumePending: rejectedConsume, + releasePending: rejectedRelease + }, + () => { + throw new Error('display failure') + } + ) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(rejectedConsume).toHaveBeenCalledOnce() + expect(rejectedRelease).toHaveBeenCalledWith(22) + }) + + it('releases the claim when acknowledgement fails or is unavailable', async () => { + const failedRelease = vi.fn().mockResolvedValue(undefined) + subscribeToMacosTccPromptNotice( + { + acknowledgePending: vi.fn().mockRejectedValue(new Error('renderer closing')), + consumePending: vi.fn().mockResolvedValue({ claimId: 11, promptCount: 3 }), + releasePending: failedRelease + }, + vi.fn() + ) + await Promise.resolve() + await Promise.resolve() + expect(failedRelease).toHaveBeenCalledWith(11) + + const unavailableRelease = vi.fn().mockResolvedValue(undefined) + subscribeToMacosTccPromptNotice( + { + consumePending: vi.fn().mockResolvedValue({ claimId: 12, promptCount: 3 }), + releasePending: unavailableRelease + }, + vi.fn() + ) + await Promise.resolve() + expect(unavailableRelease).toHaveBeenCalledWith(12) + }) + + it('falls back to live delivery when consuming throws synchronously', async () => { + const listenerState: { listener?: (payload: { promptCount: number }) => void } = {} + const onNotice = vi.fn() + const consumePending = vi.fn(() => { + throw new Error('ipc unavailable') + }) + + expect(() => + subscribeToMacosTccPromptNotice( + { + consumePending, + onThreshold: (listener) => { + listenerState.listener = listener + return vi.fn() + } + }, + onNotice + ) + ).not.toThrow() + + listenerState.listener?.({ promptCount: 3 }) + await Promise.resolve() + + expect(consumePending).toHaveBeenCalledTimes(2) + expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }) + }) + + it('releases the claim when acknowledgement throws synchronously', async () => { + const releasePending = vi.fn().mockResolvedValue(undefined) + const onNotice = vi.fn() + + subscribeToMacosTccPromptNotice( + { + acknowledgePending: vi.fn(() => { + throw new Error('ipc unavailable') + }), + consumePending: vi.fn().mockResolvedValue({ claimId: 13, promptCount: 3 }), + releasePending + }, + onNotice + ) + await new Promise((resolve) => { + setImmediate(resolve) + }) + + expect(onNotice).toHaveBeenCalledOnce() + expect(releasePending).toHaveBeenCalledWith(13) + }) + + it('keeps live delivery with an older preload that has no consume API', () => { + const listenerState: { listener?: (payload: { promptCount: number }) => void } = {} + const onNotice = vi.fn() + const unsubscribe = subscribeToMacosTccPromptNotice( + { + onThreshold: (listener) => { + listenerState.listener = listener + return vi.fn() + } + }, + onNotice + ) + + listenerState.listener?.({ promptCount: 3 }) + + expect(onNotice).toHaveBeenCalledWith({ promptCount: 3 }) + unsubscribe() + }) +}) diff --git a/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts new file mode 100644 index 00000000000..a738a486539 --- /dev/null +++ b/src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.ts @@ -0,0 +1,113 @@ +export type TccPromptNoticePayload = { promptCount: number } + +type TccPromptNoticeClaim = TccPromptNoticePayload & { claimId?: number } + +type MacosTccPromptNoticeApi = { + onThreshold?: (callback: (payload: TccPromptNoticePayload) => void) => () => void + consumePending?: () => Promise + acknowledgePending?: (claimId: number) => Promise + releasePending?: (claimId: number) => Promise + dismiss?: () => Promise +} + +export async function dismissMacosTccPromptNotice( + api: Pick | undefined +): Promise { + try { + await api?.dismiss?.() + } catch { + // Why: renderer teardown must not turn a best-effort dismissal into an unhandled error. + } +} + +export function subscribeToMacosTccPromptNotice( + api: MacosTccPromptNoticeApi | undefined, + onNotice: (payload: TccPromptNoticePayload) => void +): () => void { + const pullPending = (): Promise => { + if (!api?.consumePending) { + return Promise.resolve(null) + } + try { + return api.consumePending() + } catch (error) { + return Promise.reject(error) + } + } + const releaseClaim = async (claimId: number): Promise => { + if (!api?.releasePending) { + return false + } + try { + await api.releasePending(claimId) + return true + } catch { + return false + } + } + const acknowledgeClaim = (claimId: number): void => { + if (!api?.acknowledgePending) { + void releaseClaim(claimId) + return + } + try { + void api.acknowledgePending(claimId).catch(() => { + void releaseClaim(claimId) + }) + } catch { + void releaseClaim(claimId) + } + } + const showNotice = (payload: TccPromptNoticePayload): boolean => { + try { + onNotice(payload) + return true + } catch (error) { + console.error('[macos-tcc-prompts] Failed to show notice:', error) + return false + } + } + // Why: one retry recovers transient toast setup without spinning on a persistent renderer fault. + let displayRetryAvailable = true + const consume = (fallback?: TccPromptNoticePayload): void => { + if (!api?.consumePending) { + if (fallback) { + showNotice(fallback) + } + return + } + void pullPending().then( + (pending) => { + if (pending) { + const claimId = pending.claimId + if (!showNotice({ promptCount: pending.promptCount })) { + if (typeof claimId === 'number') { + const shouldRetry = displayRetryAvailable + displayRetryAvailable = false + void releaseClaim(claimId).then((released) => { + if (released && shouldRetry) { + consume() + } + }) + } + return + } + if (typeof claimId === 'number') { + acknowledgeClaim(claimId) + } + } + }, + () => { + if (fallback) { + showNotice(fallback) + } + } + ) + } + + const unsubscribe = api?.onThreshold?.((payload) => consume(payload)) ?? (() => {}) + // Why: the threshold can land before React subscribes or while the main window is closed. + consume() + // Why: StrictMode cleanup must not abandon a claim before it is acknowledged or released. + return unsubscribe +} diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts new file mode 100644 index 00000000000..cc9d47ab6e7 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -0,0 +1,133 @@ +import type { TerminalTab, WorkspaceSessionState } from '../../../shared/types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { splitWorktreeId } from '../../../shared/worktree-id' +import type { AppState } from '../store/types' + +function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab { + const preserved = { + ...remote, + generation: local.generation, + ptyId: local.ptyId + } + return local.pendingActivationSpawn + ? { ...preserved, pendingActivationSpawn: local.pendingActivationSpawn } + : preserved +} + +export function mergeDirectSshRemoteWorkspaceSession( + current: WorkspaceSessionState, + remote: WorkspaceSessionState, + replaceWorktreeIds: ReadonlySet, + liveTabsByWorktree: AppState['tabsByWorktree'], + preserveLocalTerminalTabIds: ReadonlySet +): WorkspaceSessionState { + const currentTabsById = new Map( + [...replaceWorktreeIds] + .flatMap((worktreeId) => liveTabsByWorktree[worktreeId] ?? []) + .map((tab) => [tab.id, tab]) + ) + const locallyPreservedTabIds = new Set() + const tabsByWorktree = Object.fromEntries( + Object.entries(remote.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => { + const local = currentTabsById.get(tab.id) + if ( + !local || + ((local.generation ?? 0) <= (tab.generation ?? 0) && + !local.pendingActivationSpawn && + !preserveLocalTerminalTabIds.has(tab.id)) + ) { + return tab + } + locallyPreservedTabIds.add(tab.id) + return preserveNewerLocalTerminalFields(tab, local) + }) + ]) + ) + const remoteTabIds = new Set( + Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) + ) + const replacedTabIds = new Set([ + ...remoteTabIds, + ...Object.entries(current.tabsByWorktree) + .filter(([worktreeId]) => replaceWorktreeIds.has(worktreeId)) + .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) + ]) + const omitTargetWorktrees = (record: Record | undefined): Record => + Object.fromEntries( + Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) + ) + const terminalLayoutsByTabId = { + ...Object.fromEntries( + Object.entries(current.terminalLayoutsByTabId).filter( + ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ) + ), + ...Object.fromEntries( + Object.entries(remote.terminalLayoutsByTabId).filter( + ([tabId]) => !locallyPreservedTabIds.has(tabId) + ) + ) + } + const activeOutsideTarget = + current.activeWorktreeId != null && !replaceWorktreeIds.has(current.activeWorktreeId) + return { + ...current, + activeRepoId: activeOutsideTarget ? current.activeRepoId : remote.activeRepoId, + activeWorktreeId: activeOutsideTarget ? current.activeWorktreeId : remote.activeWorktreeId, + activeWorkspaceKey: activeOutsideTarget + ? current.activeWorkspaceKey + : remote.activeWorktreeId + ? worktreeWorkspaceKey(remote.activeWorktreeId) + : null, + activeTabId: activeOutsideTarget ? current.activeTabId : remote.activeTabId, + tabsByWorktree: { + ...omitTargetWorktrees(current.tabsByWorktree), + ...tabsByWorktree + }, + terminalLayoutsByTabId, + activeWorktreeIdsOnShutdown: [ + ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)), + ...(remote.activeWorktreeIdsOnShutdown ?? []) + ], + activeTabIdByWorktree: { + ...omitTargetWorktrees(current.activeTabIdByWorktree), + ...remote.activeTabIdByWorktree + }, + remoteSessionIdsByTabId: { + ...Object.fromEntries( + Object.entries(current.remoteSessionIdsByTabId ?? {}).filter( + ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ) + ), + ...Object.fromEntries( + Object.entries(remote.remoteSessionIdsByTabId ?? {}).filter( + ([tabId]) => !locallyPreservedTabIds.has(tabId) + ) + ) + }, + lastVisitedAtByWorktreeId: { + ...omitTargetWorktrees(current.lastVisitedAtByWorktreeId), + ...remote.lastVisitedAtByWorktreeId + }, + defaultTerminalTabsAppliedByWorktreeId: { + ...omitTargetWorktrees(current.defaultTerminalTabsAppliedByWorktreeId), + ...remote.defaultTerminalTabsAppliedByWorktreeId + } + } +} + +export function uniqueWorktreeIdByPath( + worktreeIds: ReadonlySet +): (worktreePath: string) => string | null { + const byPath = new Map() + for (const worktreeId of worktreeIds) { + const path = splitWorktreeId(worktreeId)?.worktreePath + if (!path) { + continue + } + byPath.set(path, byPath.has(path) ? null : worktreeId) + } + return (worktreePath) => byPath.get(worktreePath) ?? null +} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts new file mode 100644 index 00000000000..1ffad891d89 --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -0,0 +1,171 @@ +import type { StoreApi } from 'zustand' +import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types' +import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import type { AppState } from '../store/types' +import { + admitDirectSshSnapshotApplyToken, + type DirectSshPreparationToken, + type DirectSshSnapshotApplyToken +} from './direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' +import { + mergeDirectSshRemoteWorkspaceSession, + uniqueWorktreeIdByPath +} from './remote-workspace-session-merge' + +const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1_000 +const SNAPSHOT_TERMINAL_RECONNECT_TIMEOUT_MS = 30_000 +let snapshotApplyDepth = 0 +let snapshotWriteSuppressUntil = 0 + +export function isDirectSshRemoteWorkspaceApplyInProgress(): boolean { + return snapshotApplyDepth > 0 || Date.now() < snapshotWriteSuppressUntil +} + +type RemoteWorkspaceSnapshotApplyInput = { + store: Pick, 'getState'> + snapshot: RemoteWorkspaceSnapshot + token: DirectSshSnapshotApplyToken + arrival: number + isArrivalCurrent: (targetId: string, arrival: number) => boolean + isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean + waitForWorkspaceSessionReady: () => Promise + finalizeHydratedTerminals: (authority: DirectSshAuthority) => number +} + +function exactTargetWorktreeIds(state: AppState, authority: DirectSshAuthority): Set { + return resolveDirectSshTargetScope({ + targetId: authority.targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }).gitWorktreeIds +} + +function currentRecoveryTabIds( + state: AppState, + authority: DirectSshAuthority, + worktreeIds: ReadonlySet +): Set { + const targetTabIds = new Set( + [...worktreeIds].flatMap((worktreeId) => + (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + ) + ) + return new Set( + [ + ...Object.entries(state.directSshPaneRetryByTabId), + ...Object.entries(state.directSshLivePtyBindingByTabId) + ] + .filter( + ([tabId, entry]) => + targetTabIds.has(tabId) && directSshAuthoritiesEqual(entry.authority, authority) + ) + .map(([tabId]) => tabId) + ) +} + +export async function applyDirectSshRemoteWorkspaceSnapshot({ + store, + snapshot, + token, + arrival, + isArrivalCurrent, + isPreparationTokenCurrent, + waitForWorkspaceSessionReady, + finalizeHydratedTerminals +}: RemoteWorkspaceSnapshotApplyInput): Promise { + const { authority } = token + if (!isArrivalCurrent(authority.targetId, arrival)) { + return + } + if ( + !isPreparationTokenCurrent(token) || + !admitDirectSshSnapshotApplyToken(token, authority, snapshot.revision) + ) { + return + } + if (!(await waitForWorkspaceSessionReady())) { + if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) { + store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + direction: 'pull', + message: translate( + 'auto.hooks.useIpcEvents.88214a785b', + 'Workspace sync waited for local session hydration and timed out' + ) + }) + } + return + } + const state = store.getState() + const worktreeIds = exactTargetWorktreeIds(state, authority) + const remoteSession = importRemoteWorkspaceSession(snapshot.session, { + resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds) + }) + const merged = mergeDirectSshRemoteWorkspaceSession( + buildWorkspaceSessionPayload(state), + remoteSession, + worktreeIds, + state.tabsByWorktree, + currentRecoveryTabIds(state, authority, worktreeIds) + ) + if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { + return + } + snapshotApplyDepth += 1 + try { + const currentStore = store.getState() + const replaceWorkspaceKeys = [...worktreeIds] + currentStore.hydrateWorkspaceSession(merged, { + directSshAuthority: authority, + replaceWorkspaceKeys + }) + currentStore.hydrateTabsSession(merged, { replaceWorkspaceKeys }) + // Why: direct SSH snapshots project terminal state only; global editor/browser hydration would reset unrelated hosts. + currentStore.markRemoteWorkspaceHydrated(authority.targetId) + currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'synced', + direction: 'pull', + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + lastSyncedAt: Date.now(), + message: translate('auto.hooks.useIpcEvents.4f78ba5885', 'Workspace synced') + }) + const reconnectAbort = new AbortController() + let reconnectTimer: ReturnType | null = null + await Promise.race([ + Promise.resolve() + .then(() => + store.getState().reconnectPersistedTerminals(reconnectAbort.signal, { + directSshAuthority: authority, + workspaceKeys: replaceWorkspaceKeys + }) + ) + .catch(() => {}), + new Promise((resolve) => { + reconnectTimer = setTimeout(() => { + reconnectAbort.abort() + resolve() + }, SNAPSHOT_TERMINAL_RECONNECT_TIMEOUT_MS) + }) + ]) + if (reconnectTimer) { + clearTimeout(reconnectTimer) + } + if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) { + finalizeHydratedTerminals(authority) + } + } finally { + snapshotWriteSuppressUntil = Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS + snapshotApplyDepth -= 1 + } +} diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts new file mode 100644 index 00000000000..985f54e535a --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -0,0 +1,557 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteWorkspacePatchResult, + RemoteWorkspaceSnapshot +} from '../../../shared/remote-workspace-types' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' +import { i18n } from '@/i18n/i18n' +import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' +import type { AppState } from '../store/types' +import type { + DirectSshPreparationInput, + DirectSshPreparationToken +} from './direct-ssh-reconnect-coordinator' +import { createRemoteWorkspaceTargetSync } from './remote-workspace-target-sync' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +const owner: DirectSshAuthority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 +} + +function token(snapshotRevision: number | null = null): DirectSshPreparationToken { + return { + authority: owner, + catalogRevision: 1, + repoFingerprint: JSON.stringify([['ssh:target-a', 'repo-a']]), + authorityRequirement: 'required', + snapshotRevision, + outcome: 'complete' + } +} + +function snapshot( + revision: number, + tabsByWorktreePath: RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'] = {} +): RemoteWorkspaceSnapshot { + return { + namespace: 'workspace', + revision, + updatedAt: revision, + schemaVersion: 1, + session: { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath, + terminalLayoutsByTabId: {} + } + } +} + +function repo(id = 'repo-a') { + return { + id, + path: `/remote/${id}`, + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } +} + +function worktree(id = 'repo-a::/remote/work') { + return { + id, + repoId: id.slice(0, id.indexOf('::')), + hostId: 'ssh:target-a' + } +} + +function appState(overrides: Record = {}): AppState { + return { + workspaceSessionReady: true, + repos: [repo()], + worktreesByRepo: { 'repo-a': [worktree()] }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + activeRepoId: null, + activeWorkspaceKey: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + terminalLayoutsByTabId: {}, + activeTabIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + markdownFrontmatterVisible: {}, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {}, + browserUrlHistory: [], + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + sshConnectionStates: new Map(), + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {}, + hydrateWorkspaceSession: vi.fn(), + hydrateTabsSession: vi.fn(), + hydrateEditorSession: vi.fn(), + hydrateBrowserSession: vi.fn(), + markRemoteWorkspaceHydrated: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + reconnectPersistedTerminals: vi.fn(async () => {}), + ...overrides + } as unknown as AppState +} + +function createHarness( + state: AppState, + get: (args: { targetId: string }) => Promise, + patchResult: RemoteWorkspacePatchResult = { ok: true, snapshot: snapshot(1) } +) { + const setForConnectedTargets = vi.fn(async () => [ + { + targetId: owner.targetId, + result: patchResult + } + ]) + let current = true + const capturePreparationInput = vi.fn( + async ( + authority: DirectSshAuthority, + reason: 'workspace-snapshot', + snapshotRevision: number + ): Promise => ({ + ...authority, + catalogRevision: 1, + repoRefs: [{ repoId: 'repo-a', executionHostId: 'ssh:target-a' }], + authorityRequirement: 'required', + reason, + snapshotRevision + }) + ) + const prepareOnly = vi.fn(async (input: DirectSshPreparationInput) => ({ + status: 'complete' as const, + token: token(input.snapshotRevision ?? null), + repoOutcomes: { + complete: 1, + 'non-authoritative': 0, + 'timed-out': 0, + 'cancel-budget-exhausted': 0, + canceled: 0, + stale: 0, + rejected: 0 + }, + lineageOutcome: 'complete' as const + })) + const finalizeHydratedTerminals = vi.fn(() => 1) + const sync = createRemoteWorkspaceTargetSync({ + store: { getState: () => state }, + remoteWorkspace: { get, setForConnectedTargets }, + getCurrentAuthority: () => (current ? owner : null), + isPreparationTokenCurrent: () => current, + capturePreparationInput, + prepareOnly, + finalizeHydratedTerminals + }) + return { + sync, + setForConnectedTargets, + capturePreparationInput, + prepareOnly, + finalizeHydratedTerminals, + makeStale: () => { + current = false + } + } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('createRemoteWorkspaceTargetSync', () => { + it('captures local tabs before get when deciding a revision-zero upload', async () => { + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] + } + }) + const pendingGet = deferred() + const harness = createHarness(state, () => pendingGet.promise) + + const pending = harness.sync.syncAfterConnect(token()) + await flush() + state.tabsByWorktree = {} + pendingGet.resolve(snapshot(0)) + await pending + + expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() + expect(harness.setForConnectedTargets).toHaveBeenCalledWith( + expect.objectContaining({ hydratedTargetIds: ['target-a'] }) + ) + }) + + it.each([ + ['stale-revision', 'Workspace changed on another device'], + ['unavailable', 'Remote workspace sync unavailable'] + ] as const)('localizes the %s upload fallback', async (reason, message) => { + const previousLanguage = i18n.language + await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE) + try { + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] + } + }) + const harness = createHarness(state, async () => snapshot(0), { + ok: false, + reason + }) + + await harness.sync.syncAfterConnect(token()) + + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith( + 'target-a', + expect.objectContaining({ message: `[${message}]` }) + ) + } finally { + await i18n.changeLanguage(previousLanguage) + } + }) + + it('publishes nothing from a snapshot response after its authority turns stale', async () => { + const state = appState() + const pendingGet = deferred() + const harness = createHarness(state, () => pendingGet.promise) + + const pending = harness.sync.syncAfterConnect(token()) + await flush() + harness.makeStale() + pendingGet.resolve(snapshot(2)) + await pending + + expect(state.hydrateTabsSession).not.toHaveBeenCalled() + expect(state.markRemoteWorkspaceHydrated).not.toHaveBeenCalled() + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledTimes(1) + expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenCalledWith('target-a', { + phase: 'pulling', + direction: 'pull' + }) + }) + + it('prepares an unsolicited snapshot once and preserves newer local terminal fields', async () => { + const calls: string[] = [] + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 7, + pendingActivationSpawn: { requestedAt: 10 } + } + ] + }, + hydrateTabsSession: vi.fn((session) => { + calls.push('hydrate') + expect(session.tabsByWorktree['repo-a::/remote/work'][0]).toMatchObject({ + id: 'stable-tab', + ptyId: 'local-pty', + generation: 7, + pendingActivationSpawn: { requestedAt: 10 } + }) + }), + reconnectPersistedTerminals: vi.fn(async () => { + calls.push('reconnect') + }) + }) + const harness = createHarness(state, async () => null) + harness.finalizeHydratedTerminals.mockImplementation(() => { + calls.push('finalize') + return 1 + }) + const incoming = snapshot(3, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'remote-pty', + generation: 99 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect(harness.capturePreparationInput).toHaveBeenCalledOnce() + expect(harness.prepareOnly).toHaveBeenCalledOnce() + expect(calls).toEqual(['hydrate', 'reconnect', 'finalize']) + expect(state.hydrateWorkspaceSession).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + directSshAuthority: owner, + replaceWorkspaceKeys: ['repo-a::/remote/work'] + }) + ) + expect(state.hydrateTabsSession).toHaveBeenCalledWith(expect.any(Object), { + replaceWorkspaceKeys: ['repo-a::/remote/work'] + }) + expect(state.hydrateEditorSession).not.toHaveBeenCalled() + expect(state.hydrateBrowserSession).not.toHaveBeenCalled() + }) + + it('preserves a higher local generation from an older remote snapshot', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 7 + } + ] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(5, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'old-remote-pty', + generation: 1 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 7, ptyId: 'local-pty' }) + }) + + it('admits a genuinely newer remote generation without local recovery state', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 1 + } + ] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(6, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'new-remote-pty', + generation: 8 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + }) + + it('does not preserve recovery evidence from another authority', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [ + { + id: 'stable-tab', + worktreeId: 'repo-a::/remote/work', + ptyId: 'local-pty', + generation: 1 + } + ] + }, + directSshLivePtyBindingByTabId: { + 'stable-tab': { + authority: { + targetId: 'target-b', + providerEpoch: 'epoch-b' as SshProviderEpoch, + connectionGeneration: 2 + }, + tabGeneration: 1, + ptyId: 'local-pty' + } + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(7, { + '/remote/work': [ + { + id: 'stable-tab', + worktreePath: '/remote/work', + ptyId: 'new-remote-pty', + generation: 8 + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + expect( + hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'][0] + ).toMatchObject({ generation: 8, ptyId: 'new-remote-pty' }) + }) + + it('does not finalize an older snapshot superseded during terminal reattach', async () => { + const firstReattach = deferred() + let reattachCount = 0 + const state = appState({ + reconnectPersistedTerminals: vi.fn(() => { + reattachCount += 1 + return reattachCount === 1 ? firstReattach.promise : Promise.resolve() + }) + }) + const harness = createHarness(state, async () => null) + + const first = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(7)) + await flush() + expect(state.reconnectPersistedTerminals).toHaveBeenCalledOnce() + const second = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(8)) + await second + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + + firstReattach.resolve() + await first + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + }) + + it('keeps staged snapshot PTYs retryable until pane transport acknowledgment', async () => { + const calls: string[] = [] + const recordLiveBindings = vi.fn(() => { + calls.push('record') + return 1 + }) + const state = appState({ + reconnectPersistedTerminals: vi.fn(async () => { + calls.push('reconnect') + }), + recordDirectSshTargetLivePtyBindings: recordLiveBindings + }) + const harness = createHarness(state, async () => null) + harness.finalizeHydratedTerminals.mockImplementation(() => { + calls.push('finalize') + return 0 + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(9)) + + expect(calls).toEqual(['reconnect', 'finalize']) + expect(recordLiveBindings).not.toHaveBeenCalled() + }) + + it('re-arms after a snapshot terminal reconnect failure', async () => { + const state = appState({ + reconnectPersistedTerminals: vi.fn(async () => { + throw new Error('reattach failed') + }) + }) + const harness = createHarness(state, async () => null) + + await harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(10)) + + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + }) + + it('times out snapshot terminal reconnect and fences its late result', async () => { + vi.useFakeTimers() + const pendingReattach = deferred() + let reconnectSignal: AbortSignal | undefined + const state = appState({ + reconnectPersistedTerminals: vi.fn((signal?: AbortSignal) => { + reconnectSignal = signal + return pendingReattach.promise + }) + }) + const harness = createHarness(state, async () => null) + + const pending = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(11)) + await vi.advanceTimersByTimeAsync(30_000) + await pending + expect(reconnectSignal?.aborted).toBe(true) + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + + pendingReattach.resolve() + await flush() + expect(harness.finalizeHydratedTerminals).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + it('fails closed on duplicate target paths and keeps folder workspaces out of projection', async () => { + const hydrateTabsSession = vi.fn() + const state = appState({ + repos: [repo('repo-a'), repo('repo-b')], + worktreesByRepo: { + 'repo-a': [worktree('repo-a::/same')], + 'repo-b': [worktree('repo-b::/same')] + }, + tabsByWorktree: { + 'folder:folder-a': [{ id: 'folder-tab', worktreeId: 'folder:folder-a', ptyId: null }] + }, + hydrateTabsSession + }) + const harness = createHarness(state, async () => null) + const incoming = snapshot(4, { + '/same': [ + { + id: 'ambiguous', + worktreePath: '/same', + ptyId: null + } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] + ] + }) + + await harness.sync.applyUnsolicitedSnapshot('target-a', incoming) + + const merged = hydrateTabsSession.mock.calls[0][0] + expect(merged.tabsByWorktree).toEqual({ + 'folder:folder-a': [{ id: 'folder-tab', worktreeId: 'folder:folder-a', ptyId: null }] + }) + }) +}) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts new file mode 100644 index 00000000000..79bc3d9690e --- /dev/null +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -0,0 +1,256 @@ +import type { StoreApi } from 'zustand' +import type { + RemoteWorkspacePatchResult, + RemoteWorkspaceSnapshot +} from '../../../shared/remote-workspace-types' +import type { WorkspaceSessionState } from '../../../shared/types' +import type { DirectSshAuthority } from '../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { buildWorkspaceSessionPayload } from '../lib/workspace-session' +import type { AppState } from '../store/types' +import type { + DirectSshPreparationInput, + DirectSshPreparationOutcome, + DirectSshPreparationToken +} from './direct-ssh-reconnect-coordinator' +import { buildDirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator' +import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' +import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' +export { isDirectSshRemoteWorkspaceApplyInProgress } from './remote-workspace-snapshot-apply' + +const WORKSPACE_HYDRATION_TIMEOUT_MS = 10_000 + +type RemoteWorkspaceApi = { + get: (args: { targetId: string }) => Promise + setForConnectedTargets: (args: { + session?: WorkspaceSessionState + hydratedTargetIds?: string[] + }) => Promise<{ targetId: string; result: RemoteWorkspacePatchResult }[]> +} + +export type RemoteWorkspaceTargetSyncDeps = { + store: Pick, 'getState'> + remoteWorkspace: RemoteWorkspaceApi + getCurrentAuthority: (targetId: string) => DirectSshAuthority | null + isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean + capturePreparationInput: ( + authority: DirectSshAuthority, + reason: 'workspace-snapshot', + snapshotRevision: number + ) => Promise + prepareOnly: (input: DirectSshPreparationInput) => Promise + finalizeHydratedTerminals: (authority: DirectSshAuthority) => number +} + +export type RemoteWorkspaceTargetSync = { + syncAfterConnect: (token: DirectSshPreparationToken) => Promise + applyUnsolicitedSnapshot: (targetId: string, snapshot: RemoteWorkspaceSnapshot) => Promise + stop: () => void +} + +function exactTargetWorktreeIds(state: AppState, authority: DirectSshAuthority): Set { + return resolveDirectSshTargetScope({ + targetId: authority.targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }).gitWorktreeIds +} + +function applyPatchStatus( + store: AppState, + targetId: string, + result: RemoteWorkspacePatchResult | undefined +): void { + if (!result) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'offline', + direction: 'push', + lastSyncedAt: Date.now(), + message: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable') + }) + } else if (result.ok) { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: 'synced', + direction: 'push', + revision: result.snapshot.revision, + updatedAt: result.snapshot.updatedAt, + lastSyncedAt: Date.now(), + message: translate('auto.hooks.useIpcEvents.f8aaf2bde3', 'Workspace uploaded') + }) + } else { + store.setRemoteWorkspaceSyncStatus(targetId, { + phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', + direction: 'push', + revision: result.snapshot?.revision, + updatedAt: result.snapshot?.updatedAt, + lastSyncedAt: Date.now(), + message: + result.message ?? + (result.reason === 'stale-revision' + ? translate( + 'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice', + 'Workspace changed on another device' + ) + : translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable')) + }) + } +} + +export function createRemoteWorkspaceTargetSync( + deps: RemoteWorkspaceTargetSyncDeps +): RemoteWorkspaceTargetSync { + const arrivalByTarget = new Map() + let stopped = false + + const beginArrival = (targetId: string): number => { + const arrival = (arrivalByTarget.get(targetId) ?? 0) + 1 + arrivalByTarget.set(targetId, arrival) + return arrival + } + + const isArrivalCurrent = (targetId: string, arrival: number): boolean => + !stopped && arrivalByTarget.get(targetId) === arrival + + const waitForWorkspaceSessionReady = async (): Promise => { + const deadline = Date.now() + WORKSPACE_HYDRATION_TIMEOUT_MS + while (!stopped && Date.now() < deadline) { + if (deps.store.getState().workspaceSessionReady) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return !stopped && deps.store.getState().workspaceSessionReady + } + + const syncAfterConnect = async (token: DirectSshPreparationToken): Promise => { + const { authority } = token + const arrival = beginArrival(authority.targetId) + const workspaceReady = await waitForWorkspaceSessionReady() + if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { + return + } + if (!workspaceReady) { + deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + direction: 'pull', + message: translate( + 'auto.hooks.useIpcEvents.88214a785b', + 'Workspace sync waited for local session hydration and timed out' + ) + }) + return + } + const stateBeforeGet = deps.store.getState() + const worktreeIds = exactTargetWorktreeIds(stateBeforeGet, authority) + const hasLocalTabs = [...worktreeIds].some( + (worktreeId) => (stateBeforeGet.tabsByWorktree[worktreeId] ?? []).length > 0 + ) + stateBeforeGet.setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'pulling', + direction: 'pull' + }) + const snapshot = await deps.remoteWorkspace.get({ targetId: authority.targetId }) + if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { + return + } + if (!snapshot) { + deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'offline', + direction: 'pull', + message: translate( + 'auto.hooks.useIpcEvents.2fe88c2e06', + 'Remote workspace sync unavailable' + ) + }) + return + } + if (snapshot.revision > 0) { + const applyToken = buildDirectSshSnapshotApplyToken(token, snapshot.revision) + if (applyToken) { + await applyDirectSshRemoteWorkspaceSnapshot({ + store: deps.store, + snapshot, + token: applyToken, + arrival, + isArrivalCurrent, + isPreparationTokenCurrent: deps.isPreparationTokenCurrent, + waitForWorkspaceSessionReady, + finalizeHydratedTerminals: deps.finalizeHydratedTerminals + }) + } + return + } + deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) + if (!hasLocalTabs) { + deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'idle', + revision: snapshot.revision, + updatedAt: snapshot.updatedAt, + message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') + }) + return + } + if (!deps.isPreparationTokenCurrent(token)) { + return + } + const results = await deps.remoteWorkspace.setForConnectedTargets({ + session: buildWorkspaceSessionPayload(deps.store.getState()), + hydratedTargetIds: [authority.targetId] + }) + if (!deps.isPreparationTokenCurrent(token)) { + return + } + const result = results.find((entry) => entry.targetId === authority.targetId)?.result + applyPatchStatus(deps.store.getState(), authority.targetId, result) + } + + const applyUnsolicitedSnapshot = async ( + targetId: string, + snapshot: RemoteWorkspaceSnapshot + ): Promise => { + const arrival = beginArrival(targetId) + const authority = deps.getCurrentAuthority(targetId) + if (!authority) { + return + } + const input = await deps.capturePreparationInput( + authority, + 'workspace-snapshot', + snapshot.revision + ) + if (!input || !isArrivalCurrent(targetId, arrival)) { + return + } + const prepared = await deps.prepareOnly(input) + if (!prepared.token || !isArrivalCurrent(targetId, arrival)) { + return + } + const applyToken = buildDirectSshSnapshotApplyToken(prepared.token, snapshot.revision) + if (applyToken) { + await applyDirectSshRemoteWorkspaceSnapshot({ + store: deps.store, + snapshot, + token: applyToken, + arrival, + isArrivalCurrent, + isPreparationTokenCurrent: deps.isPreparationTokenCurrent, + waitForWorkspaceSessionReady, + finalizeHydratedTerminals: deps.finalizeHydratedTerminals + }) + } + } + + return { + syncAfterConnect, + applyUnsolicitedSnapshot, + stop: () => { + stopped = true + arrivalByTarget.clear() + } + } +} diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 6d36c1366a3..858d23cd87b 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -648,6 +648,22 @@ describe('useComposerState host-context boundaries', () => { expect(quickSubmit).not.toContain('platform: CLIENT_PLATFORM') }) + // Why: activation no longer rebuilds a startup from `createdWithAgent`, so this + // caller's own `startup` is the only thing that launches the agent it planned. + it('passes its own startup to activation when submit planned an agent', () => { + const activation = sourceBetween( + HOOK_SOURCE, + 'const activation = activateAndRevealWorktree(worktree.id, {', + 'if (startupPlan) {' + ) + + expect(activation).toContain('...(startupPlan && !backendSpawnedStartup') + expect(activation).toContain('command: startupPlan.launchCommand') + expect(activation).toContain('launchAgent: tuiAgent') + // The removed activation-time fallback must not come back through this caller. + expect(HOOK_SOURCE).not.toContain('buildCreatedAgentReopenStartup') + }) + it('prepares linked quick-create drafts for the selected default agent', () => { const quickSubmit = sourceBetween( HOOK_SOURCE, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2411ed3b93f..c5704798190 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -140,6 +140,7 @@ import { useFolderWorkspaceComposerPathStatus } from '@/components/sidebar/folde import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit' import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' import { + getRepoExecutionHostId, normalizeExecutionHostId, parseExecutionHostId, type ExecutionHostId @@ -826,6 +827,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId: selectedRecipeRepoId, repoIsGit: selectedRepoIsGit, repoConnectionId: selectedRecipeRepoConnectionId, + repoExecutionHostId: selectedRepo ? getRepoExecutionHostId(selectedRepo) : null, projectGroupTarget: isProjectGroupTarget, initialRecipeId: initialEphemeralVmRecipeId }) @@ -3997,6 +3999,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note: trimmedNote, startupPlan, quickPrompt, + ...(quickDraftPrompt ? { launchDraftPrompt: quickDraftPrompt } : {}), quickTelemetry, ...(createMultiple ? { suppressTerminalFocusOnCompletion: true } : {}) } diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx index 6439bc43a68..5f10b7a9908 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx @@ -59,17 +59,20 @@ function installApi(listRecipes: ReturnType): { function Harness({ repoId, initialRecipeId, - enabled = true + enabled = true, + repoExecutionHostId = 'local' }: { repoId: string initialRecipeId?: string enabled?: boolean + repoExecutionHostId?: 'local' | `runtime:${string}` }): React.JSX.Element { const state = useEphemeralVmRecipeOptions({ enabled, repoId, repoIsGit: true, repoConnectionId: null, + repoExecutionHostId, projectGroupTarget: false, initialRecipeId }) @@ -78,6 +81,7 @@ function Harness({ {repoId} {state.recipes.map((recipe) => recipe.id).join(',')} {state.selectedRecipeId ?? 'none'} + {state.error ?? 'none'} ) } @@ -145,4 +149,22 @@ describe('useEphemeralVmRecipeOptions', () => { expect(listRecipes).not.toHaveBeenCalled() }) + + it('does not send a paired-runtime repo id to local recipe discovery', async () => { + const listRecipes = vi.fn().mockResolvedValue({ + status: 'error', + repoPath: null, + diagnostics: [], + recipes: [], + message: 'Repo not found: repo-runtime' + }) + installApi(listRecipes) + + const { container } = await render( + + ) + + expect(listRecipes).not.toHaveBeenCalled() + expect(container.querySelector('[data-testid="error"]')?.textContent).toBe('none') + }) }) diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts index 3bee2c8c6c3..6ebf489f894 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { OrcaVmRecipe } from '../../../shared/types' type EphemeralVmRecipeOptionsArgs = { @@ -6,6 +7,7 @@ type EphemeralVmRecipeOptionsArgs = { repoId: string | null repoIsGit: boolean repoConnectionId: string | null + repoExecutionHostId: ExecutionHostId | null projectGroupTarget: boolean initialRecipeId?: string } @@ -25,6 +27,7 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs): Boolean(args.repoId) && args.repoIsGit && !args.repoConnectionId && + args.repoExecutionHostId === LOCAL_EXECUTION_HOST_ID && !args.projectGroupTarget const load = useCallback( diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 08c49e1f3e9..39ef0eaea7c 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -16,6 +16,10 @@ import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-ty import type { TuiAgent } from '../../../shared/types' import { makePaneKey } from '../../../shared/stable-pane-id' import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' +import { + FLOATING_WORKSPACE_GUEST_CLOSE_EVENT, + FLOATING_WORKSPACE_GUEST_SELECT_INDEX_EVENT +} from '@/lib/floating-workspace-guest-bridge' const { closeTerminalTabMock } = vi.hoisted(() => ({ closeTerminalTabMock: vi.fn() @@ -1002,6 +1006,8 @@ describe('useIpcEvents browser tab create routing', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -1220,6 +1226,8 @@ describe('useIpcEvents updater integration', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -1426,7 +1434,7 @@ describe('useIpcEvents updater integration', () => { }) it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { - const clearTabPtyId = vi.fn() + const clearDirectSshTargetPtyBindings = vi.fn(() => 1) const setSshConnectionState = vi.fn() const setSshTargetsMetadata = vi.fn() const clearRemovedSshTargetState = vi.fn() @@ -1478,7 +1486,7 @@ describe('useIpcEvents updater integration', () => { removeSshCredentialRequest: vi.fn(), clearRemoteDetectedAgents: vi.fn(), clearRemovedSshTargetState, - clearTabPtyId, + clearDirectSshTargetPtyBindings, repos: [{ id: 'repo-1', connectionId: 'conn-1' }], worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] @@ -1590,6 +1598,8 @@ describe('useIpcEvents updater integration', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -1663,8 +1673,8 @@ describe('useIpcEvents updater integration', () => { 'conn-1', expect.objectContaining({ status: 'disconnected' }) ) - expect(clearTabPtyId).toHaveBeenCalledWith('tab-1') - expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2') + expect(clearDirectSshTargetPtyBindings).toHaveBeenCalledOnce() + expect(clearDirectSshTargetPtyBindings).toHaveBeenCalledWith('conn-1') expect(storeState.clearRemoteDetectedAgents).toHaveBeenCalledWith('conn-1') setSshConnectionState.mockClear() @@ -2099,6 +2109,8 @@ describe('useIpcEvents updater integration', () => { return () => {} }, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -2934,6 +2946,9 @@ describe('useIpcEvents browser tab close routing', () => { beforeEach(() => { vi.resetModules() vi.unstubAllGlobals() + // Undo a partial mock of this module leaked by an earlier describe so the real + // resolveFloatingWorkspaceBrowserWorkspaceId (source validation) is used here. + vi.doUnmock('@/lib/floating-workspace-terminal-actions') closeTerminalTabMock.mockReset() }) @@ -2943,12 +2958,16 @@ describe('useIpcEvents browser tab close routing', () => { worktreeId?: string }) => void type CloseActiveTabListener = () => void + type CloseFloatingItemListener = (payload: { sourceId: string }) => void + type SelectFloatingIndexListener = (payload: { index: number }) => void type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void type CloseSessionTabListener = (data: { tabId: string; worktreeId: string }) => void type TerminalTabCloseRequestListener = (data: { requestId: string; tabId: string }) => void async function useIpcEventsForCloseRouting({ closeActiveTabListenerRef, + closeFloatingItemListenerRef, + selectFloatingIndexListenerRef, closeSessionTabListenerRef, closeTerminalListenerRef, getState, @@ -2959,6 +2978,8 @@ describe('useIpcEvents browser tab close routing', () => { persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) }: { closeActiveTabListenerRef?: { current: CloseActiveTabListener | null } + closeFloatingItemListenerRef?: { current: CloseFloatingItemListener | null } + selectFloatingIndexListenerRef?: { current: SelectFloatingIndexListener | null } closeSessionTabListenerRef?: { current: CloseSessionTabListener | null } closeTerminalListenerRef?: { current: CloseTerminalListener | null } getState: () => Record @@ -3132,6 +3153,18 @@ describe('useIpcEvents browser tab close routing', () => { } return () => {} }, + onCloseFloatingItem: (listener: CloseFloatingItemListener) => { + if (closeFloatingItemListenerRef) { + closeFloatingItemListenerRef.current = listener + } + return () => {} + }, + onSelectFloatingIndex: (listener: SelectFloatingIndexListener) => { + if (selectFloatingIndexListenerRef) { + selectFloatingIndexListenerRef.current = listener + } + return () => {} + }, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -3671,6 +3704,8 @@ describe('useIpcEvents browser tab close routing', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -3890,6 +3925,8 @@ describe('useIpcEvents browser tab close routing', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -4104,6 +4141,8 @@ describe('useIpcEvents browser tab close routing', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -4171,6 +4210,95 @@ describe('useIpcEvents browser tab close routing', () => { error: 'Browser tab missing-page not found' }) }) + + // The floating-guest close receiver validates the source id still names a live floating + // browser tab, then re-dispatches a typed window event for the mounted panel. + function dispatchedEventTypes(): string[] { + const dispatchEvent = (window.dispatchEvent as ReturnType).mock.calls + return dispatchEvent.map((call) => (call[0] as Event).type) + } + + // Main forwards the guest's page id, so the receiver must resolve it to the owning workspace id. + it('re-dispatches a floating-guest close for a live floating browser page source', async () => { + const closeFloatingItemListenerRef: { current: CloseFloatingItemListener | null } = { + current: null + } + + await useIpcEventsForCloseRouting({ + closeFloatingItemListenerRef, + getState: () => ({ + browserTabsByWorktree: { 'global-floating-terminal': [{ id: 'workspace-1' }] }, + browserPagesByWorkspace: { 'workspace-1': [{ id: 'page-1' }] } + }) + }) + + closeFloatingItemListenerRef.current?.({ sourceId: 'page-1' }) + + const closeEvents = (window.dispatchEvent as ReturnType).mock.calls + .map((call) => call[0] as CustomEvent) + .filter((event) => event.type === FLOATING_WORKSPACE_GUEST_CLOSE_EVENT) + expect(closeEvents).toHaveLength(1) + expect(closeEvents[0].detail).toEqual({ sourceId: 'workspace-1' }) + }) + + it('re-dispatches a floating-guest close for a live floating browser workspace source', async () => { + const closeFloatingItemListenerRef: { current: CloseFloatingItemListener | null } = { + current: null + } + + await useIpcEventsForCloseRouting({ + closeFloatingItemListenerRef, + getState: () => ({ + browserTabsByWorktree: { 'global-floating-terminal': [{ id: 'workspace-1' }] }, + browserPagesByWorkspace: { 'workspace-1': [{ id: 'page-1' }] } + }) + }) + + closeFloatingItemListenerRef.current?.({ sourceId: 'workspace-1' }) + + const closeEvents = (window.dispatchEvent as ReturnType).mock.calls + .map((call) => call[0] as CustomEvent) + .filter((event) => event.type === FLOATING_WORKSPACE_GUEST_CLOSE_EVENT) + expect(closeEvents).toHaveLength(1) + expect(closeEvents[0].detail).toEqual({ sourceId: 'workspace-1' }) + }) + + it('ignores a floating-guest close for a stale/unknown source (no-op)', async () => { + const closeFloatingItemListenerRef: { current: CloseFloatingItemListener | null } = { + current: null + } + + await useIpcEventsForCloseRouting({ + closeFloatingItemListenerRef, + getState: () => ({ + browserTabsByWorktree: { 'global-floating-terminal': [{ id: 'workspace-1' }] }, + browserPagesByWorkspace: { 'workspace-1': [{ id: 'page-1' }] } + }) + }) + + closeFloatingItemListenerRef.current?.({ sourceId: 'already-closed' }) + + expect(dispatchedEventTypes()).not.toContain(FLOATING_WORKSPACE_GUEST_CLOSE_EVENT) + }) + + it('re-dispatches a floating-guest index select', async () => { + const selectFloatingIndexListenerRef: { current: SelectFloatingIndexListener | null } = { + current: null + } + + await useIpcEventsForCloseRouting({ + selectFloatingIndexListenerRef, + getState: () => ({}) + }) + + selectFloatingIndexListenerRef.current?.({ index: 2 }) + + const selectEvents = (window.dispatchEvent as ReturnType).mock.calls + .map((call) => call[0] as CustomEvent) + .filter((event) => event.type === FLOATING_WORKSPACE_GUEST_SELECT_INDEX_EVENT) + expect(selectEvents).toHaveLength(1) + expect(selectEvents[0].detail).toEqual({ index: 2 }) + }) }) describe('useIpcEvents CLI-created worktree activation', () => { @@ -4336,6 +4464,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -4595,6 +4725,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -4805,6 +4937,8 @@ describe('useIpcEvents agent status snapshot integration', () => { drop?: (paneKey: string) => void remoteWorkspace?: Record runtime?: Record + ssh?: Record + ui?: Record }): Record { return { api: { @@ -4856,6 +4990,8 @@ describe('useIpcEvents agent status snapshot integration', () => { replyTabSetProfile: () => {}, onNewTerminalTab: () => () => {}, onCloseActiveTab: () => () => {}, + onCloseFloatingItem: () => () => {}, + onSelectFloatingIndex: () => () => {}, onSwitchTab: () => () => {}, onSwitchTabAcrossAllTypes: () => () => {}, onSwitchRecentTab: () => () => {}, @@ -4864,7 +5000,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onFullscreenChanged: () => () => {}, onTerminalZoom: () => () => {}, getZoomLevel: () => 0, - set: vi.fn() + set: vi.fn(), + ...args.ui }, settings: { onChanged: () => () => {} }, updater: { @@ -4901,7 +5038,8 @@ describe('useIpcEvents agent status snapshot integration', () => { onCredentialRequest: () => () => {}, onCredentialResolved: () => () => {}, onPortForwardsChanged: () => () => {}, - onDetectedPortsChanged: () => () => {} + onDetectedPortsChanged: () => () => {}, + ...args.ssh }, agentStatus: { onSet: args.onSet, @@ -4947,11 +5085,321 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) } + function buildSshAuthorityReconciliationHarness(args: { + partialAuthority: { providerEpoch?: string; connectionGeneration?: number } + latestAuthority: { providerEpoch: string; connectionGeneration: number } + }): { + emitPartialState: () => void + getState: ReturnType + requestReconnect: ReturnType + setSshConnectionState: ReturnType + storedState: () => Record | undefined + } { + const targetId = 'target-reconciliation' + const baseState = { + targetId, + status: 'connected' as const, + error: null, + reconnectAttempt: 0 + } + const partialState = { ...baseState, ...args.partialAuthority } + const latestState = { ...baseState, ...args.latestAuthority } + const sshConnectionStates = new Map>() + let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined + const getState = vi.fn(() => Promise.resolve(latestState)) + const requestReconnect = vi.fn(async () => ({ status: 'complete' })) + const setSshConnectionState = vi.fn((nextTargetId: string, state: Record) => { + sshConnectionStates.set(nextTargetId, state) + }) + const storeState = buildStoreState({ + sshTargetLabels: new Map([[targetId, 'Reconciliation Target']]), + sshConnectionStates, + setSshConnectionState, + invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0), + retryDirectSshTargetPanes: vi.fn(() => 0), + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + clearRemoteDetectedAgents: vi.fn(), + clearDirectSshTargetPtyBindings: vi.fn(), + clearRemovedSshTargetState: vi.fn() + }) + const coordinator = { + requestReconnect, + replaceAuthority: vi.fn(), + prepareOnly: vi.fn(), + correctUnboundTerminals: vi.fn(() => 0), + finalizeHydratedTerminals: vi.fn(() => 0), + invalidate: vi.fn(), + stop: vi.fn() + } + + stubReactSyncEffect() + stubAuxiliaryModules() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./direct-ssh-reconnect-rollout', () => ({ + isDirectSshReconnectCoordinatorRoutingEnabled: () => true + })) + vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ + createDirectSshWorktreeRefreshScheduler: () => ({ + stop: vi.fn(), + disposeProvider: vi.fn() + }) + })) + vi.doMock('./direct-ssh-host-hydration', () => ({ + createDirectSshHostHydration: () => ({ + capturePreparationInput: vi.fn(), + readHostScopedLineage: vi.fn(), + isPreparationTokenCurrent: vi.fn(() => true), + stop: vi.fn() + }) + })) + vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ + createDirectSshReconnectCoordinator: () => coordinator + })) + vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ + createDirectSshReconnectProductTelemetryAdapter: vi.fn() + })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + ssh: { + getState, + onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => { + sshStateListener = listener + return () => {} + } + } + }) + ) + + return { + emitPartialState: () => { + if (!sshStateListener) { + throw new Error('Expected SSH state listener') + } + sshStateListener({ targetId, state: partialState }) + }, + getState, + requestReconnect, + setSshConnectionState, + storedState: () => sshConnectionStates.get(targetId) + } + } + beforeEach(() => { vi.resetModules() vi.unstubAllGlobals() }) + it.each([ + { partialAuthority: { providerEpoch: 'epoch-current' } }, + { partialAuthority: { connectionGeneration: 7 } } + ])( + 'fills a same-watermark partial SSH authority and routes it once', + async ({ partialAuthority }) => { + const harness = buildSshAuthorityReconciliationHarness({ + partialAuthority, + latestAuthority: { + providerEpoch: 'epoch-current', + connectionGeneration: 7 + } + }) + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + harness.emitPartialState() + + await vi.waitFor(() => { + expect(harness.requestReconnect).toHaveBeenCalledOnce() + }) + expect(harness.getState).toHaveBeenCalledOnce() + expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2) + expect(harness.storedState()).toEqual({ + targetId: 'target-reconciliation', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-current', + connectionGeneration: 7 + }) + } + ) + + it.each([ + { partialAuthority: { providerEpoch: 'epoch-conflict' } }, + { partialAuthority: { connectionGeneration: 6 } } + ])( + 'rejects a reconciliation reply that conflicts with present authority', + async ({ partialAuthority }) => { + const harness = buildSshAuthorityReconciliationHarness({ + partialAuthority, + latestAuthority: { + providerEpoch: 'epoch-current', + connectionGeneration: 7 + } + }) + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + harness.emitPartialState() + + await vi.waitFor(() => { + expect(harness.getState).toHaveBeenCalledOnce() + }) + await Promise.resolve() + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.setSshConnectionState).toHaveBeenCalledOnce() + expect(harness.storedState()).toEqual( + expect.objectContaining({ + targetId: 'target-reconciliation', + ...partialAuthority + }) + ) + } + ) + + it.each([ + { enabled: true, expectedRoute: ['request'] }, + { + enabled: false, + expectedRoute: ['replace', 'invalidate', 'retry', 'capture', 'prepare'] + } + ])( + 'routes a changed direct SSH authority through the enabled=$enabled path', + async ({ enabled, expectedRoute }) => { + const order: string[] = [] + let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined + const oldState = { + targetId: 'target-a', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-old', + connectionGeneration: 1 + } + const nextState = { + ...oldState, + providerEpoch: 'epoch-new', + connectionGeneration: 2 + } + const storeState = buildStoreState({ + sshTargetLabels: new Map([['target-a', 'Target A']]), + sshConnectionStates: new Map([['target-a', oldState]]), + setSshConnectionState: (targetId: string, state: unknown) => { + ;(storeState.sshConnectionStates as Map).set(targetId, state) + }, + invalidateStaleDirectSshTargetPtyBindings: () => { + order.push('invalidate') + return 1 + }, + retryDirectSshTargetPanes: () => { + order.push('retry') + return 1 + }, + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + clearRemoteDetectedAgents: vi.fn(), + clearDirectSshTargetPtyBindings: vi.fn(), + clearRemovedSshTargetState: vi.fn() + }) + const coordinator = { + requestReconnect: vi.fn(async () => { + order.push('request') + return { status: 'complete' } + }), + replaceAuthority: vi.fn(() => { + order.push('replace') + }), + prepareOnly: vi.fn(async () => { + order.push('prepare') + return { token: null } + }), + correctUnboundTerminals: vi.fn(() => 0), + finalizeHydratedTerminals: vi.fn(() => 0), + invalidate: vi.fn(), + stop: vi.fn() + } + const capturePreparationInput = vi.fn(async (authority, reason) => { + order.push('capture') + return { + ...authority, + reason, + catalogRevision: 1, + repoRefs: [], + authorityRequirement: 'required' + } + }) + + stubReactSyncEffect() + stubAuxiliaryModules() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./direct-ssh-reconnect-rollout', () => ({ + isDirectSshReconnectCoordinatorRoutingEnabled: () => enabled + })) + vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ + createDirectSshWorktreeRefreshScheduler: () => ({ + stop: vi.fn(), + disposeProvider: vi.fn() + }) + })) + vi.doMock('./direct-ssh-host-hydration', () => ({ + createDirectSshHostHydration: () => ({ + capturePreparationInput, + readHostScopedLineage: vi.fn(), + isPreparationTokenCurrent: vi.fn(() => true), + stop: vi.fn() + }) + })) + vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ + createDirectSshReconnectCoordinator: () => coordinator + })) + vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ + createDirectSshReconnectProductTelemetryAdapter: vi.fn() + })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + ssh: { + onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => { + sshStateListener = listener + return () => {} + } + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + sshStateListener?.({ targetId: 'target-a', state: nextState }) + await Promise.resolve() + await Promise.resolve() + + expect(order).toEqual(expectedRoute) + if (enabled) { + expect(coordinator.requestReconnect).toHaveBeenCalledOnce() + expect(coordinator.prepareOnly).not.toHaveBeenCalled() + } else { + expect(coordinator.requestReconnect).not.toHaveBeenCalled() + expect(coordinator.replaceAuthority).toHaveBeenCalledOnce() + expect(coordinator.prepareOnly).toHaveBeenCalledOnce() + } + } + ) + it('caps pending mobile state events while startup hydration is unresolved', async () => { const setFitOverride = vi.fn() const hydrateOverrides = vi.fn() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 66b4ffd241f..68a94915300 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2,7 +2,6 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { useAppStore } from '../store' -import { shouldRetryPaneSpawnOnSshReconnect } from './ssh-reconnect-pane-retry' import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' @@ -16,7 +15,10 @@ import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoa import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' -import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' +import { + hasRegisteredRuntimeTerminalTab, + focusRuntimeTerminalSurface +} from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -24,22 +26,17 @@ import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-fon import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, - UpdateStatus, - WorkspaceSessionState + UpdateStatus } from '../../../shared/types' -import type { - RemoteWorkspacePatchResult, - RemoteWorkspaceSnapshot -} from '../../../shared/remote-workspace-types' import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { SshConnectionState } from '../../../shared/ssh-types' +import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' +import { toSshExecutionHostId } from '../../../shared/execution-host' import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { RuntimeBrowserDriverState, RuntimeTerminalPresentation, RuntimeTerminalDriverState } from '../../../shared/runtime-types' -import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' import { stepUIZoomLevel } from '../../../shared/ui-zoom-level' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' @@ -72,7 +69,6 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' @@ -122,8 +118,13 @@ import { createFloatingWorkspaceTerminalTab, isEmptyFloatingWorkspacePanelVisible, isFloatingWorkspacePanelFocused, + resolveFloatingWorkspaceBrowserWorkspaceId, switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions' +import { + dispatchFloatingWorkspaceGuestClose, + dispatchFloatingWorkspaceGuestSelectIndex +} from '@/lib/floating-workspace-guest-bridge' import { observeAgentHookCompletionForNotification, resetAgentHookCompletionNotificationCoordinators, @@ -141,6 +142,27 @@ import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { getConnectionIdFromState } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' +import { createDirectSshWorktreeRefreshScheduler } from './direct-ssh-worktree-refresh-scheduler' +import { + createDirectSshReconnectCoordinator, + type DirectSshPreparationInput, + type DirectSshPreparationReason +} from './direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' +import { createDirectSshHostHydration } from './direct-ssh-host-hydration' +import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' +import { + createRemoteWorkspaceTargetSync, + isDirectSshRemoteWorkspaceApplyInProgress, + type RemoteWorkspaceTargetSync +} from './remote-workspace-target-sync' +import { + registerDirectSshWakeRouting, + routeDirectSshConnectedState, + type DirectSshConnectedStateOrigin +} from './direct-ssh-state-routing' +import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout' function getShortcutPlatform(): NodeJS.Platform { if (navigator.userAgent.includes('Mac')) { @@ -252,9 +274,6 @@ const MAX_PENDING_MOBILE_STATE_EVENTS = 300 // Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 const recentlyRenamedWorktreeIdExpiry = new Map() -let remoteWorkspaceSnapshotApplyDepth = 0 -let remoteWorkspaceSnapshotWriteSuppressUntil = 0 -const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000 function isAgentStatusForRecentlyClosedTab( store: Pick, @@ -413,262 +432,7 @@ function activateExistingLeafInLayout( } export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean { - return ( - remoteWorkspaceSnapshotApplyDepth > 0 || Date.now() < remoteWorkspaceSnapshotWriteSuppressUntil - ) -} - -async function waitForWorkspaceSessionReady(): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (useAppStore.getState().workspaceSessionReady) { - return true - } - await new Promise((resolve) => window.setTimeout(resolve, 100)) - } - return useAppStore.getState().workspaceSessionReady -} - -async function prepareRemoteWorkspaceTarget(targetId: string): Promise { - if (!(await waitForWorkspaceSessionReady())) { - return false - } - const store = useAppStore.getState() - let repos = store.repos.filter((repo) => repo.connectionId === targetId) - if (repos.length === 0) { - await store.fetchRepos() - repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId) - } - await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) - await useAppStore.getState().fetchWorktreeLineage() - return true -} - -function targetRepoIds(targetId: string): Set { - return new Set( - useAppStore - .getState() - .repos.filter((repo) => repo.connectionId === targetId) - .map((repo) => repo.id) - ) -} - -function targetWorktreeIds(targetId: string): Set { - const repoIds = targetRepoIds(targetId) - return new Set( - Object.values(useAppStore.getState().worktreesByRepo) - .flat() - .filter((worktree) => repoIds.has(worktree.repoId)) - .map((worktree) => worktree.id) - ) -} - -function mergeRemoteWorkspaceSession( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - targetId: string -): WorkspaceSessionState { - const replaceWorktreeIds = targetWorktreeIds(targetId) - const remoteTabIds = new Set( - Object.values(remote.tabsByWorktree) - .flat() - .map((tab) => tab.id) - ) - const replacedTabIds = new Set([ - ...remoteTabIds, - ...Object.entries(current.tabsByWorktree) - .filter(([worktreeId]) => replaceWorktreeIds.has(worktreeId)) - .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) - ]) - const omitTargetWorktrees = (record: Record | undefined): Record => - Object.fromEntries( - Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId)) - ) - - return { - ...current, - activeRepoId: - remote.activeRepoId ?? - (current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId) - ? null - : current.activeRepoId), - activeWorktreeId: - remote.activeWorktreeId ?? - (current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId) - ? null - : current.activeWorktreeId), - activeTabId: - remote.activeTabId ?? - (current.activeTabId && replacedTabIds.has(current.activeTabId) ? null : current.activeTabId), - tabsByWorktree: { - ...omitTargetWorktrees(current.tabsByWorktree), - ...remote.tabsByWorktree - }, - terminalLayoutsByTabId: { - ...Object.fromEntries( - Object.entries(current.terminalLayoutsByTabId).filter( - ([tabId]) => !replacedTabIds.has(tabId) - ) - ), - ...remote.terminalLayoutsByTabId - }, - activeWorktreeIdsOnShutdown: [ - ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)), - ...(remote.activeWorktreeIdsOnShutdown ?? []) - ], - activeTabIdByWorktree: { - ...omitTargetWorktrees(current.activeTabIdByWorktree), - ...remote.activeTabIdByWorktree - }, - remoteSessionIdsByTabId: { - ...Object.fromEntries( - Object.entries(current.remoteSessionIdsByTabId ?? {}).filter( - ([tabId]) => !replacedTabIds.has(tabId) - ) - ), - ...remote.remoteSessionIdsByTabId - }, - lastVisitedAtByWorktreeId: { - ...omitTargetWorktrees(current.lastVisitedAtByWorktreeId), - ...remote.lastVisitedAtByWorktreeId - } - } -} - -async function applyRemoteWorkspaceSnapshot( - targetId: string, - snapshot: RemoteWorkspaceSnapshot -): Promise { - if (!(await prepareRemoteWorkspaceTarget(targetId))) { - throw new Error('Workspace sync waited for local session hydration and timed out') - } - const worktreeIds = targetWorktreeIds(targetId) - const localByPath = new Map( - Array.from(worktreeIds).map((worktreeId) => { - const separator = worktreeId.indexOf('::') - return [separator === -1 ? worktreeId : worktreeId.slice(separator + 2), worktreeId] as const - }) - ) - const remoteSession = importRemoteWorkspaceSession(snapshot.session, { - resolveWorktreeId: (worktreePath) => localByPath.get(worktreePath) ?? null - }) - const current = buildWorkspaceSessionPayload(useAppStore.getState()) - const merged = mergeRemoteWorkspaceSession(current, remoteSession, targetId) - const store = useAppStore.getState() - remoteWorkspaceSnapshotApplyDepth += 1 - try { - store.hydrateWorkspaceSession(merged) - store.hydrateTabsSession(merged) - store.hydrateEditorSession(merged) - store.hydrateBrowserSession(merged) - store.markRemoteWorkspaceHydrated(targetId) - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: 'synced', - direction: 'pull', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.4f78ba5885', 'Workspace synced') - }) - await useAppStore.getState().reconnectPersistedTerminals() - } finally { - // Why: reattach updates pty ids/titles after hydration; they came from the snapshot, don't echo back as a new revision. - remoteWorkspaceSnapshotWriteSuppressUntil = - Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS - remoteWorkspaceSnapshotApplyDepth -= 1 - } -} - -async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise { - const store = useAppStore.getState() - if (!(await prepareRemoteWorkspaceTarget(targetId))) { - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: 'error', - direction: 'pull', - message: translate( - 'auto.hooks.useIpcEvents.88214a785b', - 'Workspace sync waited for local session hydration and timed out' - ) - }) - return - } - store.setRemoteWorkspaceSyncStatus(targetId, { phase: 'pulling', direction: 'pull' }) - const worktreeIds = targetWorktreeIds(targetId) - const hasLocalTabs = Array.from(worktreeIds).some( - (worktreeId) => (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).length > 0 - ) - const snapshot = await window.api.remoteWorkspace.get({ targetId }) - if (!snapshot) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'offline', - direction: 'pull', - message: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable') - }) - return - } - if (snapshot.revision > 0) { - await applyRemoteWorkspaceSnapshot(targetId, snapshot) - return - } - - useAppStore.getState().markRemoteWorkspaceHydrated(targetId) - if (hasLocalTabs) { - // Why: read the relay before publishing local tabs, or a reconnect can overwrite a newer snapshot with stale local state. - const session = buildWorkspaceSessionPayload(useAppStore.getState()) - const results = await window.api.remoteWorkspace.setForConnectedTargets({ - session, - hydratedTargetIds: [targetId] - }) - const result = results.find((entry) => entry.targetId === targetId)?.result - applyRemoteWorkspacePatchStatus(targetId, result) - if (result?.ok) { - useAppStore.getState().markRemoteWorkspaceHydrated(targetId) - } - return - } - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'idle', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') - }) -} - -function applyRemoteWorkspacePatchStatus( - targetId: string, - result: RemoteWorkspacePatchResult | undefined -): void { - if (!result) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'offline', - direction: 'push', - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable') - }) - return - } - if (result.ok) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'synced', - direction: 'push', - revision: result.snapshot.revision, - updatedAt: result.snapshot.updatedAt, - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.f8aaf2bde3', 'Workspace uploaded') - }) - return - } - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', - direction: 'push', - revision: result.snapshot?.revision, - updatedAt: result.snapshot?.updatedAt, - lastSyncedAt: Date.now(), - message: - result.message ?? - (result.reason === 'stale-revision' - ? 'Workspace changed on another device' - : 'Remote workspace sync unavailable') - }) + return isDirectSshRemoteWorkspaceApplyInProgress() } type BrowserSessionTabTarget = @@ -848,6 +612,149 @@ function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] + const reconnectAuthorityByTarget = new Map() + const authorityReconciliationDeadlines = new Set<{ + timer: ReturnType + settle: () => void + }>() + let directSshEffectStopped = false + const currentDirectSshAuthority = (targetId: string): DirectSshAuthority | null => { + const state = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + state?.status !== 'connected' || + state.targetId !== targetId || + !state.providerEpoch || + state.connectionGeneration === undefined + ) { + return null + } + return { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + } + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt: (key) => { + const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { + repoId: key.repoId, + executionHostId: key.executionHostId, + authority: { + targetId: key.targetId, + providerEpoch: key.providerEpoch, + connectionGeneration: key.connectionGeneration + }, + requireAuthoritative: key.authorityRequirement === 'required' + }) + return { + providerRequestId: acquired.providerRequestId, + result: acquired.result.then((result) => acquired.merge(result)), + cancel: acquired.release + } + } + }) + const hostHydration = createDirectSshHostHydration({ + store: useAppStore, + isCurrentAuthority: (authority) => + directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), + listRepos: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.repos.listForExecutionHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ + authoritative: false, + executionHostId, + reason: 'unavailable' as const + }) + ) + }, + listLineage: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.worktrees.listLineageForHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ + authoritative: false, + executionHostId, + reason: 'unavailable' as const + }) + ) + } + }) + type DirectSshTerminalActions = Partial< + Pick + > + const directSshTerminalActions = (): DirectSshTerminalActions => + useAppStore.getState() as DirectSshTerminalActions + let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null + const reconnectCoordinator = createDirectSshReconnectCoordinator({ + scheduler, + isCurrentConnectedAuthority: (authority) => + directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), + capturePreparationInput: hostHydration.capturePreparationInput, + readHostScopedLineage: hostHydration.readHostScopedLineage, + invalidateStaleTerminalBindings: (authority) => + directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, + retryTargetPanes: (authority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + finalizeHydratedTerminalPanes: (authority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + correctUnboundTerminalPanes: (authority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + syncRemoteWorkspaceAfterConnect: (token) => + remoteWorkspaceTargetSync?.syncAfterConnect(token), + onTelemetry: createDirectSshReconnectProductTelemetryAdapter() + }) + const remoteWorkspaceApi = window.api.remoteWorkspace + if (remoteWorkspaceApi) { + remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ + store: useAppStore, + remoteWorkspace: remoteWorkspaceApi, + getCurrentAuthority: currentDirectSshAuthority, + isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, + capturePreparationInput: (authority, reason, snapshotRevision) => + hostHydration.capturePreparationInput(authority, reason, snapshotRevision), + prepareOnly: reconnectCoordinator.prepareOnly, + finalizeHydratedTerminals: (authority) => + directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) + ? reconnectCoordinator.finalizeHydratedTerminals(authority) + : 0 + }) + } + const prepareAndSyncDirectSshTarget = async ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + options?: { authorityAlreadyReplaced?: boolean } + ): Promise => { + try { + if (!options?.authorityAlreadyReplaced) { + reconnectCoordinator.replaceAuthority(authority) + } + const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( + authority, + reason + ) + if (!input) { + return + } + const prepared = await reconnectCoordinator.prepareOnly(input) + if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) { + await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token) + } + } catch (error) { + if (directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority)) { + useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + } + } const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose) type PendingAgentStatusEvent = { @@ -1089,40 +996,38 @@ export function useIpcEvents(): void { let reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey( reachableRuntimeEnvironmentIds ) - unsubs.push( - useAppStore.subscribe(() => { - const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() - const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) - const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() - const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) - if ( - nextKey === runtimeClientEventEnvironmentKey && - nextReachableKey === reachableRuntimeEnvironmentKey - ) { - return - } - for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: runtimeClientEventEnvironmentIds, - nextDesired: nextEnvironmentIds, - previousReachable: reachableRuntimeEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - })) { - runtimeProjectRefreshScheduler.request(environmentId) - } - for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( - reachableRuntimeEnvironmentIds, - nextReachableEnvironmentIds - )) { - // No-op when the environment has no SSH bucket (e.g. web client). - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - } - runtimeClientEventEnvironmentIds = nextEnvironmentIds - runtimeClientEventEnvironmentKey = nextKey - reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds - reachableRuntimeEnvironmentKey = nextReachableKey - runtimeClientEventsSync.sync() - }) - ) + const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe(() => { + const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() + const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) + const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() + const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) + if ( + nextKey === runtimeClientEventEnvironmentKey && + nextReachableKey === reachableRuntimeEnvironmentKey + ) { + return + } + for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: runtimeClientEventEnvironmentIds, + nextDesired: nextEnvironmentIds, + previousReachable: reachableRuntimeEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + })) { + runtimeProjectRefreshScheduler.request(environmentId) + } + for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( + reachableRuntimeEnvironmentIds, + nextReachableEnvironmentIds + )) { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + } + runtimeClientEventEnvironmentIds = nextEnvironmentIds + runtimeClientEventEnvironmentKey = nextKey + reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds + reachableRuntimeEnvironmentKey = nextReachableKey + runtimeClientEventsSync.sync() + }) unsubs.push(runtimeClientEventsSync.stop) unsubs.push(runtimeProjectRefreshScheduler.stop) @@ -2602,6 +2507,27 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onCloseFloatingItem(({ sourceId }) => { + // Main forwards the guest's browser *page* id; resolve it to the owning live floating + // browser workspace (the id space the panel closes by), then hand off to the mounted + // panel's own close closure (pin guard + reclaim intent). Stale id = no-op. + const workspaceId = resolveFloatingWorkspaceBrowserWorkspaceId( + useAppStore.getState(), + sourceId + ) + if (!workspaceId) { + return + } + dispatchFloatingWorkspaceGuestClose({ sourceId: workspaceId }) + }) + ) + unsubs.push( + window.api.ui.onSelectFloatingIndex(({ index }) => { + dispatchFloatingWorkspaceGuestSelectIndex({ index }) + }) + ) + unsubs.push( window.api.ui.onSwitchTab((direction) => { const store = useAppStore.getState() @@ -2662,40 +2588,60 @@ export function useIpcEvents(): void { unsubs.push(unsubscribeWorkspaceSpaceProgress) } + const sshStateWatermarkByTargetId = new Map() + let applySshConnectionStateChange!: ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ) => void + // Why: hydrate initial SSH state for all targets so worktree cards show correct connect state on launch. void (async () => { try { const targets = await window.api.ssh.listTargets() + if (directSshEffectStopped) { + return + } useAppStore.getState().setSshTargetsMetadata(targets) // Why: ghost-host UI (removed target still referenced by a workspace) shows a tombstone name instead of the raw id. try { const removedLabels = await window.api.ssh.listRemovedTargetLabels() + if (directSshEffectStopped) { + return + } useAppStore.getState().setRemovedSshTargetLabels(removedLabels) } catch { // Best-effort — a missing map just falls back to the raw target id. } for (const target of targets) { + const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 const state = await window.api.ssh.getState({ targetId: target.id }) - if (state) { - useAppStore.getState().setSshConnectionState(target.id, state as SshConnectionState) + if ( + !directSshEffectStopped && + state && + (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark + ) { + applySshConnectionStateChange( + target.id, + state as SshConnectionState, + 'initial-hydration' + ) // Why: ports arrive only via push events; on reattach to a live session fetch snapshots or the Ports panel shows empty. if ((state as SshConnectionState).status === 'connected') { + const authority = currentDirectSshAuthority(target.id) const [forwards, detected] = await Promise.all([ window.api.ssh.listPortForwards({ targetId: target.id }), window.api.ssh.listDetectedPorts({ targetId: target.id }) ]) // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. - const currentState = useAppStore.getState().sshConnectionStates.get(target.id) - if (currentState?.status === 'connected') { + if ( + !directSshEffectStopped && + authority && + directSshAuthoritiesEqual(currentDirectSshAuthority(target.id), authority) + ) { useAppStore.getState().setPortForwards(target.id, forwards) useAppStore.getState().setDetectedPorts(target.id, detected) } - void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, { - phase: 'error', - message: err instanceof Error ? err.message : 'Workspace sync failed' - }) - }) } } } @@ -2728,12 +2674,75 @@ export function useIpcEvents(): void { }) ) - const applySshConnectionStateChange = (targetId: string, state: SshConnectionState): void => { + const reconcileSshAuthority = ( + targetId: string, + initiatingState: SshConnectionState, + origin: DirectSshConnectedStateOrigin, + watermark: number + ): void => { + let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined + const deadline = new Promise((resolve) => { + const settle = (): void => resolve(null) + const timer = setTimeout(settle, 5_000) + pendingDeadline = { timer, settle } + authorityReconciliationDeadlines.add(pendingDeadline) + }) + void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) + .then((latest) => { + if ( + directSshEffectStopped || + latest?.targetId !== targetId || + !latest?.providerEpoch || + latest.connectionGeneration === undefined || + sshStateWatermarkByTargetId.get(targetId) !== watermark + ) { + return + } + const current = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + current?.status !== initiatingState.status || + latest.status !== initiatingState.status || + current.providerEpoch !== initiatingState.providerEpoch || + current.connectionGeneration !== initiatingState.connectionGeneration || + (current.providerEpoch !== undefined && + current.providerEpoch !== null && + current.providerEpoch !== latest.providerEpoch) || + (current.connectionGeneration !== undefined && + current.connectionGeneration !== latest.connectionGeneration) + ) { + return + } + applySshConnectionStateChange( + targetId, + { + ...current, + providerEpoch: latest.providerEpoch, + connectionGeneration: latest.connectionGeneration + }, + origin + ) + }) + .catch(() => undefined) + .finally(() => { + if (pendingDeadline) { + clearTimeout(pendingDeadline.timer) + authorityReconciliationDeadlines.delete(pendingDeadline) + } + }) + } + + applySshConnectionStateChange = ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ): void => { const store = useAppStore.getState() + const previous = store.sshConnectionStates?.get(targetId) store.setSshConnectionState(targetId, state) - const remoteRepos = store.repos.filter((r) => r.connectionId === targetId) if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) { + reconnectAuthorityByTarget.delete(targetId) + reconnectCoordinator.invalidate(targetId) // Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay. store.clearRemoteDetectedAgents(targetId) @@ -2741,62 +2750,54 @@ export function useIpcEvents(): void { store.clearPortForwards(targetId) store.setDetectedPorts(targetId, []) - // Why: SSH teardown fires no per-PTY exit events; clear stale PTY ids so reconnect remounts rather than reattach a dead PTY. - const remoteWorktreeIds = new Set( - Object.values(store.worktreesByRepo) - .flat() - .filter((w) => remoteRepos.some((r) => r.id === w.repoId)) - .map((w) => w.id) + // SSH teardown has no per-PTY exits; clear only exact-target bindings in one store publication. + store.clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status !== 'connected') { + return + } + const authority = currentDirectSshAuthority(targetId) + if (!authority) { + reconcileSshAuthority( + targetId, + state, + origin, + sshStateWatermarkByTargetId.get(targetId) ?? 0 ) - for (const worktreeId of remoteWorktreeIds) { - const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] - for (const tab of tabs) { - if (tab.ptyId) { - useAppStore.getState().clearTabPtyId(tab.id) + return + } + const previousAuthority = + previous?.status === 'connected' && + previous.providerEpoch && + previous.connectionGeneration !== undefined + ? { + targetId, + providerEpoch: previous.providerEpoch, + connectionGeneration: previous.connectionGeneration + } + : null + routeDirectSshConnectedState( + { + coordinator: reconnectCoordinator, + coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), + invalidateStaleTerminalBindings: (nextAuthority) => + directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? + 0, + retryTargetPanes: (nextAuthority) => + directSshTerminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, + prepareAndSync: prepareAndSyncDirectSshTarget, + rememberReconnectAuthority: (nextAuthority) => { + if (nextAuthority) { + reconnectAuthorityByTarget.set(targetId, nextAuthority) + } else { + reconnectAuthorityByTarget.delete(targetId) } } - } - } - - if (state.status === 'connected') { - void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { - await useAppStore.getState().fetchWorktreeLineage() - // Why: panes that never spawned (no PTY provider at cold start) or whose deferred reattach never ran sit inert. - // Bumping generation remounts TerminalPane so the deferred-connect gate reattaches or spawns fresh now that the provider exists. - const freshStore = useAppStore.getState() - const remoteRepoIds = new Set(remoteRepos.map((r) => r.id)) - const worktreeIds = Object.values(freshStore.worktreesByRepo) - .flat() - .filter((w) => remoteRepoIds.has(w.repoId)) - .map((w) => w.id) - - for (const worktreeId of worktreeIds) { - const tabs = freshStore.tabsByWorktree[worktreeId] ?? [] - const needsRetry = (t: { id: string; ptyId?: string | null }): boolean => - shouldRetryPaneSpawnOnSshReconnect({ - targetId, - tabPtyId: t.ptyId, - deferredSessionId: freshStore.deferredSshSessionIdsByTabId[t.id] - }) - if (tabs.some(needsRetry)) { - useAppStore.setState((s) => ({ - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) => - needsRetry(t) ? { ...t, generation: (t.generation ?? 0) + 1 } : t - ) - } - })) - } - } - void syncRemoteWorkspaceAfterConnect(targetId).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, { - phase: 'error', - message: err instanceof Error ? err.message : 'Workspace sync failed' - }) - }) - }) - } + }, + { authority, previousAuthority, origin } + ) } let sshTargetStateEventId = 0 @@ -2806,6 +2807,10 @@ export function useIpcEvents(): void { const store = useAppStore.getState() const state = data.state as SshConnectionState const stateEventId = ++sshTargetStateEventId + sshStateWatermarkByTargetId.set( + data.targetId, + (sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1 + ) latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) if (!store.sshTargetLabels.has(data.targetId)) { // Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first. @@ -2818,6 +2823,9 @@ export function useIpcEvents(): void { return } latestSshTargetStateEventByTargetId.delete(data.targetId) + if (directSshEffectStopped) { + return + } const latestStore = useAppStore.getState() if (!targets.some((target) => target.id === data.targetId)) { // Why: state events can race after target removal; absence from main's target list means deletion, not a new target. @@ -2825,22 +2833,37 @@ export function useIpcEvents(): void { return } latestStore.setSshTargetsMetadata(targets) - applySshConnectionStateChange(data.targetId, state) + applySshConnectionStateChange(data.targetId, state, 'push') }) .catch(() => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { + if ( + !directSshEffectStopped && + latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId + ) { latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) + applySshConnectionStateChange(data.targetId, state, 'push') } }) return } latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state) + applySshConnectionStateChange(data.targetId, state, 'push') } unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) + unsubs.push( + registerDirectSshWakeRouting({ + getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], + wakeAuthority: (authority) => { + reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') + void prepareAndSyncDirectSshTarget(authority, 'wake-refresh') + }, + ...(typeof window.api.ui.onSystemResumed === 'function' + ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } + : {}) + }) + ) let remoteWorkspaceClientId: string | null = null let remoteWorkspaceClientIdPromise: Promise | null = null @@ -2871,13 +2894,15 @@ export function useIpcEvents(): void { if (event.sourceClientId && clientId && event.sourceClientId === clientId) { return } - await applyRemoteWorkspaceSnapshot(event.targetId, event.snapshot).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { - phase: 'error', - revision: event.snapshot.revision, - message: err instanceof Error ? err.message : 'Failed to apply remote workspace' + await remoteWorkspaceTargetSync + ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) + .catch((err) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { + phase: 'error', + revision: event.snapshot.revision, + message: err instanceof Error ? err.message : 'Failed to apply remote workspace' + }) }) - }) })() }) ) @@ -3316,13 +3341,11 @@ export function useIpcEvents(): void { // Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered. requestAgentStatusSnapshotIfReady() - unsubs.push( - useAppStore.subscribe((state, previousState) => { - requestAgentStatusSnapshotIfReady() - flushPendingAgentStatuses() - syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) - }) - ) + const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => { + requestAgentStatusSnapshotIfReady() + flushPendingAgentStatuses() + syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) + }) let mobileStateHydrated = isRuntimeEnvironmentActive() type PendingMobileStateEvent = @@ -3450,7 +3473,19 @@ export function useIpcEvents(): void { pendingAgentStatusEvents.length = 0 mobileStateHydrationDisposed = true pendingMobileStateEvents.length = 0 + unsubscribeRuntimeEnvironmentStore() + unsubscribeAgentStatusStore() unsubs.forEach((fn) => fn()) + directSshEffectStopped = true + for (const deadline of authorityReconciliationDeadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + authorityReconciliationDeadlines.clear() + remoteWorkspaceTargetSync?.stop() + hostHydration.stop() + reconnectCoordinator.stop() + reconnectAuthorityByTarget.clear() resetAgentHookCompletionNotificationCoordinators() } }, []) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx new file mode 100644 index 00000000000..a31e97d2b66 --- /dev/null +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { I18nextProvider } from 'react-i18next' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { UI_LANGUAGE_SPANISH } from '../../../shared/ui-language' +import { useAppStore } from '@/store' +import { usePluginLanguagePackStore } from '@/store/plugin-language-packs' +import { i18n } from '@/i18n/i18n' +import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost' +import { useMacosTccPromptNotice } from './useMacosTccPromptNotice' + +const subscribeToMacosTccPromptNotice = vi.hoisted(() => vi.fn(() => vi.fn())) + +vi.mock('./macos-tcc-prompt-notice-subscription', () => ({ + dismissMacosTccPromptNotice: vi.fn(), + subscribeToMacosTccPromptNotice +})) + +const initialAppState = useAppStore.getInitialState() +const initialPluginLanguagePackState = usePluginLanguagePackStore.getInitialState() +let root: Root | null = null + +function NoticeProbe(): null { + useMacosTccPromptNotice() + return null +} + +beforeEach(async () => { + useAppStore.setState(initialAppState, true) + usePluginLanguagePackStore.setState(initialPluginLanguagePackState, true) + subscribeToMacosTccPromptNotice.mockClear() + await i18n.changeLanguage('en') +}) + +afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + root = null + } + useAppStore.setState(initialAppState, true) + usePluginLanguagePackStore.setState(initialPluginLanguagePackState, true) +}) + +it('waits for the persisted locale catalog before consuming the one-time notice', async () => { + const container = document.createElement('div') + root = createRoot(container) + await act(async () => { + root?.render(createElement(I18nextProvider, { i18n }, createElement(NoticeProbe))) + }) + expect(subscribeToMacosTccPromptNotice).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: UI_LANGUAGE_SPANISH } + }) + }) + expect(subscribeToMacosTccPromptNotice).not.toHaveBeenCalled() + + await act(async () => { + await i18n.changeLanguage('es') + }) + expect(subscribeToMacosTccPromptNotice).toHaveBeenCalledOnce() +}) + +it('isolates plugin language-pack discovery from its parent render path', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' } + }) + let parentRenderCount = 0 + function ParentProbe(): React.JSX.Element { + parentRenderCount += 1 + return createElement(MacosTccPromptNoticeHost) + } + + const container = document.createElement('div') + root = createRoot(container) + await act(async () => { + root?.render(createElement(I18nextProvider, { i18n }, createElement(ParentProbe))) + }) + await act(async () => { + usePluginLanguagePackStore.setState({ packs: [], loaded: true }) + }) + + expect(parentRenderCount).toBe(1) + expect(subscribeToMacosTccPromptNotice).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.ts b/src/renderer/src/hooks/useMacosTccPromptNotice.ts new file mode 100644 index 00000000000..d94d70de57f --- /dev/null +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { isPluginUiLanguage } from '../../../shared/ui-language' +import { useAppStore } from '@/store' +import { usePluginLanguagePackStore } from '@/store/plugin-language-packs' +import { translate } from '@/i18n/i18n' +import { resolveUiLocale } from '@/i18n/supported-languages' +import { + dismissMacosTccPromptNotice, + subscribeToMacosTccPromptNotice +} from './macos-tcc-prompt-notice-subscription' + +/** + * Shows the Full Disk Access hint only after macOS has repeatedly raised its + * consent dialog naming Orca (#9756). The main process counts the dialogs, so + * users who never see one never see this. + */ +export function useMacosTccPromptNotice(): void { + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null) + const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs) + const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded) + const { i18n } = useTranslation() + const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage) + const targetLocale = + uiLanguage === null || (isPluginUiLanguage(uiLanguage) && !pluginLanguagePacksLoaded) + ? null + : (selectedPluginLanguage?.resourceLanguage ?? + (isPluginUiLanguage(uiLanguage) ? 'en' : resolveUiLocale(uiLanguage))) + const localeReady = + targetLocale !== null && + i18n.language === targetLocale && + i18n.hasResourceBundle(targetLocale, 'translation') + + useEffect(() => { + if (!localeReady) { + return + } + return subscribeToMacosTccPromptNotice(window.api?.macosTccPrompts, () => { + toast.warning( + translate( + 'auto.hooks.useMacosTccPromptNotice.title', + 'Reduce repeated macOS file-access prompts' + ), + { + description: translate( + 'auto.hooks.useMacosTccPromptNotice.description', + 'macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.' + ), + duration: 12_000, + action: { + label: translate('auto.hooks.useMacosTccPromptNotice.openSettings', 'Open Settings'), + onClick: () => { + openSettingsPage() + openSettingsTarget({ pane: 'developer-permissions', repoId: null }) + } + }, + cancel: { + label: translate('auto.hooks.useMacosTccPromptNotice.dismiss', "Don't show again"), + onClick: () => { + void dismissMacosTccPromptNotice(window.api?.macosTccPrompts) + } + } + } + ) + }) + }, [localeReady, openSettingsPage, openSettingsTarget]) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8af57bc0104..2a8120cabf7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -356,6 +356,7 @@ "0708ed89f1": "Claude", "fc80296033": "Devin", "da41abbdd4": "Ante", + "060d152fb5": "Trae", "mimo_code_label": "MiMo Code" }, "skill": { @@ -701,6 +702,7 @@ "60428567b4": "Local terminal reveal is unavailable while a remote runtime is active", "f8aaf2bde3": "Workspace uploaded", "2fe88c2e06": "Remote workspace sync unavailable", + "workspaceChangedOnAnotherDevice": "Workspace changed on another device", "2ec42e1c52": "No remote workspace yet", "88214a785b": "Workspace sync waited for local session hydration and timed out", "4f78ba5885": "Workspace synced", @@ -790,6 +792,12 @@ }, "useLargeTextControlPaste": { "pasteTooLarge": "Paste is too large." + }, + "useMacosTccPromptNotice": { + "title": "Reduce repeated macOS file-access prompts", + "description": "macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.", + "openSettings": "Open Settings", + "dismiss": "Don't show again" } }, "components": { @@ -5641,7 +5649,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "Apple Events for scripts that control other local apps.", "e119f0d66b": "Automation", - "7ca17b62c8": "Recommended when projects, worktrees, or symlinked files touch macOS-protected folders.", + "7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca and Orca Helper to reduce those prompts. Then quit Orca, end any surviving Orca Helper process, and reopen Orca.", "c566bca278": "Full Disk Access", "9f35980756": "Keystroke injection, window control, and UI automation tools.", "5b2f22ca2d": "Accessibility", @@ -11404,16 +11412,25 @@ "searchGitLab": "Search GitLab MRs and issues", "searchBranches": "Search branches", "searchLinear": "Search Linear issues", - "workspaceName": "Workspace name" + "workspaceName": "Workspace name", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "No hosts are ready for this project.", "placeholder": "Choose host" }, "ProjectCombobox": { - "search": "Search projects...", "empty": "No projects match your search.", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, @@ -11999,7 +12016,24 @@ "orchestration": { "OrchestrationPage": { "30b509a467": "2 children", - "862605d066": "2 child workspaces" + "862605d066": "2 child workspaces", + "coordinatorName": "redesign auth flow", + "childPr1Name": "PR 1/2: migrate users.sql", + "childPr2Name": "PR 2/2: withSession middleware" + }, + "types": { + "coordinatorInitial": "Splitting auth rewrite into 2 PRs…", + "codexInitial": "Writing the users table migration…", + "claudeInitial": "Sketching withSession middleware…", + "codexBeat1": "Adding the email_verified column…", + "claudeBeat1": "Wiring withSession middleware…", + "coordinatorBeat1": "PR 1/2 ready", + "coordinatorBeat2": "PR 2/2 ready" + }, + "steps": { + "name": "Orchestration", + "subtitle": "Orchestration", + "description": "Enable agents to manage and coordinate Orca workspaces to execute larger tasks." }, "StatusesPage": { "2f549fc0ba": "src/auth/session.test.ts", @@ -12287,6 +12321,7 @@ "4cc7b83ffe": "Filter files...", "21783df79f": "Collapse file tree", "481e63ca52": "Files", + "resizeFileTree": "Resize file tree", "d5ac717d65": "uncommitted", "39b6b9e4e4": "Committed on Branch" }, @@ -12918,6 +12953,9 @@ "bb7f599ee7": "Open Settings", "2d5b9fabf9": "Microphone access denied. Grant access in system settings, then restart Orca.", "5d2c3e7ae3": "No speech detected." + }, + "DictationIndicator": { + "335e1bc6cb": "Stop dictation" } }, "dashboard": { @@ -13333,9 +13371,11 @@ "d90981f766": "Time", "6b914c5fbb": "Day", "233b8c94b6": "Cadence", - "b08ccb4d06": "hourly", - "8cc026fd73": "weekly", - "c3e39e17cf": "custom" + "55b2ef82a4": "Hourly", + "f0202f3a89": "Daily", + "57e83307d0": "Weekdays", + "837d902bba": "Weekly", + "ddba78647e": "Custom cron" }, "AutomationSessionField": { "f3c76dce51": "Reuse", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d60f8704ecf..6aa511a9481 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -27,7 +27,7 @@ "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", - "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", + "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del daemon y análisis de disco de los espacios de trabajo.", "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." @@ -333,6 +333,7 @@ "0708ed89f1": "Claude", "fc80296033": "Devin", "da41abbdd4": "Ante", + "060d152fb5": "Trae", "mimo_code_label": "MiMo Code" }, "skill": { @@ -617,8 +618,8 @@ "codex": { "session": { "restart": { - "4bd4a3a9c7": "System default", - "9f0b1c2d3e": "Codex account" + "4bd4a3a9c7": "Predeterminado del sistema", + "9f0b1c2d3e": "Cuenta de Codex" } } }, @@ -678,6 +679,7 @@ "60428567b4": "Mostrar terminales locales no está disponible mientras haya un host remoto activo", "f8aaf2bde3": "Espacio de trabajo subido", "2fe88c2e06": "La sincronización del espacio de trabajo remoto no está disponible", + "workspaceChangedOnAnotherDevice": "El espacio de trabajo cambió en otro dispositivo", "2ec42e1c52": "Aún no hay espacio de trabajo remoto", "88214a785b": "La sincronización del espacio de trabajo esperó a que la sesión local terminara de cargarse y agotó el tiempo de espera", "4f78ba5885": "Espacio de trabajo sincronizado", @@ -767,6 +769,12 @@ }, "useLargeTextControlPaste": { "pasteTooLarge": "El contenido pegado es demasiado grande." + }, + "useMacosTccPromptNotice": { + "title": "Reduce los avisos repetidos de acceso a archivos de macOS", + "description": "macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.", + "openSettings": "Abrir ajustes", + "dismiss": "No volver a mostrar" } }, "components": { @@ -775,8 +783,8 @@ "9263e75f49": "Codex está usando la cuenta anterior.", "a4c8e1b2f7": "Codex sigue conectado como {{value0}}", "d3e8a1f4b2": "Cuenta cambiada", - "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account" + "9375620cc3": "Reinicia esta sesión para usar {{value0}}. Se mantiene en la cuenta anterior hasta que lo hagas.", + "6133594b12": "Mantener cuenta anterior" }, "FirstLaunchBanner": { "b9e1b966c7": "Descartar aviso", @@ -1889,18 +1897,18 @@ "d5253b54af": "error", "7ffc08506e": "check", "522df222b9": "spinner", - "e944c2de43": "Update Verification Blocked", - "5b309b19f3": "Update Wasn't Installed", - "092f09fc14": "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version.", - "c9ff9b9ec2": "Check official releases", - "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", - "5194358929": "Hide details", - "8bc9e17d8f": "Show details", - "8cf17b10af": "Local Build Error", - "a4650b0dc4": "Could Not Use Local Build", - "b1e390250d": "Could not complete the local build switch.", - "d29740d175": "The selected build could not be used.", - "37d45c9ec1": "Choose Another Build" + "e944c2de43": "Verificación de actualización bloqueada", + "5b309b19f3": "La actualización no se instaló", + "092f09fc14": "El editor del instalador no coincide con Orca, así que detuvimos la actualización. No instales esta descarga; consulta las versiones oficiales para una versión corregida.", + "c9ff9b9ec2": "Verificar versiones oficiales", + "a05992a26b": "La verificación de firma no pudo ejecutarse — generalmente porque el software antivirus la bloqueó. Reintenta la descarga u obtén el instalador de nuestras versiones oficiales.", + "5194358929": "Ocultar detalles", + "8bc9e17d8f": "Mostrar detalles", + "8cf17b10af": "Error de compilación local", + "a4650b0dc4": "No se pudo usar la compilación local", + "b1e390250d": "No se pudo completar el cambio de compilación local.", + "d29740d175": "La compilación seleccionada no pudo ser usada.", + "37d45c9ec1": "Elegir otra compilación" }, "WorktreeJumpPalette": { "ac037cfac2": "Mover", @@ -3049,17 +3057,17 @@ "298f4be7f2": "CPU", "2aa2de6cb9": "Nombre", "30ff2c3c31": "{{value0}} huérfano", - "6449a95c78": "Cuánta RAM física de esta máquina están usando los procesos que Orca rastrea.", + "6449a95c78": "Cuánta RAM física de esta máquina ocupan los procesos que Orca rastrea.", "e7ccce7e87": "de RAM del sistema", "9e2525c89f": "Memoria residente de Orca más los procesos bajo los terminales de cada worktree.", "1fedf94eae": "Carga de CPU combinada. Los valores superiores al 100% significan que más de un núcleo está trabajando a la vez.", "e7cf14ec78": "Sesiones de terminal no disponibles. La lista puede estar obsoleta.", "93b0de3c21": "Reiniciar", "f85af9cda6": "Las snapshots de recursos y las sesiones de terminal no están disponibles.", - "f8e0d794b4": "El servicio no responde", + "f8e0d794b4": "El daemon no responde", "bd19fd7a59": "Terminar todas las sesiones", - "c9382662bb": "Reiniciar servicio", - "59f178fe11": "{{value0}}, servicio inaccesible", + "c9382662bb": "Reiniciar daemon", + "59f178fe11": "{{value0}}, daemon inalcanzable", "21cacb16d1": "· remoto", "73a3fd68a9": "Contraer repositorio", "b12e31dfcb": "Expandir repositorio", @@ -3074,13 +3082,13 @@ "138b99bd80": "esta sesión", "888dad8c55": "Cargando…", "6d9793d4bc": "Administrador de recursos - Terminales", - "ca95d077db": "servicio inaccesible", + "ca95d077db": "daemon inalcanzable", "a82253b458": "Eliminar espacio de trabajo.", "946724a70a": "El espacio de trabajo principal no se puede eliminar.", "16bc3c998a": "Eliminar espacio de trabajo {{value0}}", "0f9e50eb07": "Otro", "d406915b78": "Renderer", - "81cd37af99": "Main", + "81cd37af99": "Principal", "fa6d36758d": "Terminar sesión {{value0}}", "b8f4a2c1d0e3": "{{value0}} huérfanos", "c7e3b1a0d9f2": "Terminar {{value0}} terminal huérfano", @@ -3125,7 +3133,7 @@ "75ded02687": "Administrar cuentas…", "ff0fbe9311": "Activo", "7657e3db9c": "Cuenta del Codex", - "38b5647724": "Servicio de uso de Codex", + "38b5647724": "Uso de Codex", "ba55303942": "Abrir detalles de Codex y cambiar de cuenta", "4dff061aab": "·", "2483c60695": "···", @@ -3139,7 +3147,7 @@ "c98ea88392": "Ninguna otra cuenta", "9332ba8684": "Cambiar a", "d450654fa2": "Cuenta de Claude", - "11e2354daf": "Servicio de uso de Claude", + "11e2354daf": "Uso de Claude", "3dd7ddfae1": "Abrir detalles de Claude y cambiar de cuenta", "59c6e7b4e0": "Las sesiones visibles se reinician ahora. Las demás se reinician cuando su worktree se activa.", "c676918adc": "Predeterminado del sistema", @@ -3159,9 +3167,9 @@ "5e5f9f5160": "1 reset de rate limit disponible", "5ecae9197c": "{{value0}} resets de rate limit disponibles", "25d8bbde69": "Aplicando reset…", - "e159fc1fd7": "Resetear ahora", + "e159fc1fd7": "Reiniciar ahora", "972a1ff497": "¿Restablecer límites de Codex?", - "6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.", + "6d1042aa6f": "Esto usa un crédito de reinicio de rate limit de Codex para la cuenta activa y reinicia de inmediato las ventanas de uso elegibles.", "f077f586db": "No volver a preguntar", "c0e972d726": "Cancelar", "06741a2f3d": "Abrir detalles de uso de MiniMax", @@ -3174,10 +3182,10 @@ }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", - "caa0f39811": "Soporta:", + "caa0f39811": "Admite:", "97957ad3a3": "Conecta tus cuentas de proveedores de IA para ver su uso en tiempo real y cambiar fácilmente entre cuentas.", "9a542f46c7": "Ocultar de la barra de estado", - "84c3b15dca": "Límites de uso de Agents", + "84c3b15dca": "Límites de uso de agentes", "d663430cf9": "Configurar seguimiento de uso" }, "UpdateStatusSegment": { @@ -5618,7 +5626,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "Apple Events para scripts que controlan otras apps locales.", "e119f0d66b": "Automatización", - "7ca17b62c8": "Recomendado cuando proyectos, worktrees o archivos symlinked tocan carpetas protegidas de macOS.", + "7ca17b62c8": "Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.", "c566bca278": "Acceso total al disco", "9f35980756": "Herramientas de inyección de teclas, control de ventanas y automatización de UI.", "5b2f22ca2d": "Accesibilidad", @@ -5670,15 +5678,15 @@ "defaultViewNative": "Chat UI" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.", - "copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.", - "toggleLabel": "Toggle Agent Dashboard", - "modeLabel": "Open as", - "modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.", - "modeAriaLabel": "Agent Dashboard open mode", - "modeInWindow": "In-window", - "modePopout": "Pop-out" + "title": "Panel de agentes", + "description": "Tablero Kanban para monitorear agentes en diferentes worktrees, en ventana o como ventana emergente.", + "copy": "Agrega una entrada del Panel de agentes a la barra lateral izquierda. Ábrelo para monitorear agentes en atención, trabajando y en pausa, y saltar a sus terminales en vivo.", + "toggleLabel": "Alternar Panel de agentes", + "modeLabel": "Abrir como", + "modeCopy": "Muestra el tablero como un panel en ventana junto a la barra lateral o como una ventana emergente separada.", + "modeAriaLabel": "Modo de apertura del Panel de agentes", + "modeInWindow": "En ventana", + "modePopout": "Ventana emergente" } }, "FloatingWorkspacePane": { @@ -7873,8 +7881,8 @@ "grok": "grok" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out." + "title": "Panel de agentes", + "description": "Tablero Kanban para monitorear agentes en diferentes worktrees, en ventana o como ventana emergente." } } }, @@ -11381,16 +11389,25 @@ "searchGitLab": "Buscar MRs e issues de GitLab", "searchBranches": "Buscar ramas", "searchLinear": "Buscar issues de Linear", - "workspaceName": "Nombre del espacio de trabajo" + "workspaceName": "Nombre del espacio de trabajo", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "No hay hosts listos para este proyecto.", "placeholder": "Elegir host" }, "ProjectCombobox": { - "search": "Buscar proyectos...", "empty": "Ningún proyecto coincide con tu búsqueda.", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, @@ -11975,8 +11992,25 @@ "agents": { "orchestration": { "OrchestrationPage": { - "30b509a467": "2 secundarios", - "862605d066": "2 workspaces secundarios" + "30b509a467": "2 workspaces secundarios", + "862605d066": "2 workspaces secundarios", + "coordinatorName": "rediseñar el flujo de autenticación", + "childPr1Name": "PR 1/2: migrar users.sql", + "childPr2Name": "PR 2/2: middleware withSession" + }, + "types": { + "coordinatorInitial": "Dividiendo la reescritura de autenticación en 2 PRs…", + "codexInitial": "Escribiendo la migración de la tabla users…", + "claudeInitial": "Diseñando el middleware withSession…", + "codexBeat1": "Agregando la columna email_verified…", + "claudeBeat1": "Conectando el middleware withSession…", + "coordinatorBeat1": "PR 1/2 listo", + "coordinatorBeat2": "PR 2/2 listo" + }, + "steps": { + "name": "Orquestación", + "subtitle": "Orquestación", + "description": "Permite que los agentes administren y coordinen los workspaces de Orca para ejecutar tareas más grandes." }, "StatusesPage": { "2f549fc0ba": "src/auth/session.test.ts", @@ -12264,6 +12298,7 @@ "4cc7b83ffe": "Filtrar archivos...", "21783df79f": "Contraer árbol de archivos", "481e63ca52": "Archivos", + "resizeFileTree": "Cambiar el tamaño del árbol de archivos", "d5ac717d65": "sin commit", "39b6b9e4e4": "Commits en la rama" }, @@ -12271,7 +12306,7 @@ "35cc27aeb2": "en Control de código fuente", "e3b9a6ce02": "más", "1da745c551": "Enviado", - "84898c548d": "Borrar ", + "84898c548d": "Borrar", "88b70d0ef5": "Copiar", "bb84b4c374": "Notas de IA", "948a5fd6c8": "Borrar notas", @@ -12895,6 +12930,9 @@ "bb7f599ee7": "Abrir configuración", "2d5b9fabf9": "Acceso al micrófono denegado. Conceda acceso en la configuración del sistema, luego reinicie Orca.", "5d2c3e7ae3": "No se detectó ninguna voz." + }, + "DictationIndicator": { + "335e1bc6cb": "Detener dictado" } }, "dashboard": { @@ -13310,9 +13348,11 @@ "d90981f766": "Tiempo", "6b914c5fbb": "Día", "233b8c94b6": "Cadencia", - "b08ccb4d06": "cada hora", - "8cc026fd73": "semanalmente", - "c3e39e17cf": "costumbre" + "55b2ef82a4": "Cada hora", + "f0202f3a89": "Diario", + "57e83307d0": "Días laborables", + "837d902bba": "Semanal", + "ddba78647e": "Cron personalizado" }, "AutomationSessionField": { "f3c76dce51": "Reutilizar", @@ -14218,12 +14258,12 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "Agent Dashboard settings", - "settingsTooltip": "Board settings" + "settings": "Configuración del Panel de agentes", + "settingsTooltip": "Configuración del tablero" }, "dashboard": { "sidebar": { - "label": "Agent Dashboard" + "label": "Panel de agentes" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e625872dbf1..cb2ded075df 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -27,7 +27,7 @@ "kimiToggleDescription": "Kimi サブスクリプション", "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", - "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", + "resourceUsageToggleDescription": "リソースマネージャーを表示します。クリックすると、CPU、メモリ、セッション、デーモン コントロール、ワークスペース ディスクスキャンの情報を確認できます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" @@ -333,6 +333,7 @@ "0708ed89f1": "Claude", "fc80296033": "Devin", "da41abbdd4": "Ante", + "060d152fb5": "Trae", "mimo_code_label": "MiMo Code" }, "skill": { @@ -678,6 +679,7 @@ "60428567b4": "リモート ランタイムがアクティブな間はローカル terminal の公開は利用できません", "f8aaf2bde3": "ワークスペースがアップロードされました", "2fe88c2e06": "リモートワークスペースの同期は利用できません", + "workspaceChangedOnAnotherDevice": "ワークスペースが別のデバイスで変更されました", "2ec42e1c52": "リモートワークスペースはまだありません", "88214a785b": "ワークスペースの同期はローカル セッションのハイドレーションを待機し、タイムアウトになりました", "4f78ba5885": "ワークスペースが同期されました", @@ -767,6 +769,12 @@ }, "useLargeTextControlPaste": { "pasteTooLarge": "貼り付け内容が大きすぎます。" + }, + "useMacosTccPromptNotice": { + "title": "macOSのファイルアクセス権限の確認を減らす", + "description": "エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。", + "openSettings": "設定を開く", + "dismiss": "今後表示しない" } }, "components": { @@ -775,8 +783,8 @@ "9263e75f49": "Codex は前のアカウントを使用しています", "a4c8e1b2f7": "Codex はまだ {{value0}} としてサインインしています", "d3e8a1f4b2": "アカウントを切り替えました", - "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account" + "9375620cc3": "このセッションを再起動して {{value0}} を使用してください。それまでは前のアカウントのままです。", + "6133594b12": "以前のアカウントを保持" }, "FirstLaunchBanner": { "b9e1b966c7": "通知を閉じる", @@ -1889,18 +1897,18 @@ "d5253b54af": "エラー", "7ffc08506e": "チェック", "522df222b9": "スピナー", - "e944c2de43": "Update Verification Blocked", - "5b309b19f3": "Update Wasn't Installed", - "092f09fc14": "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version.", - "c9ff9b9ec2": "Check official releases", - "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", - "5194358929": "Hide details", - "8bc9e17d8f": "Show details", - "8cf17b10af": "Local Build Error", - "a4650b0dc4": "Could Not Use Local Build", - "b1e390250d": "Could not complete the local build switch.", - "d29740d175": "The selected build could not be used.", - "37d45c9ec1": "Choose Another Build" + "e944c2de43": "更新の確認がブロックされました", + "5b309b19f3": "更新がインストールされませんでした", + "092f09fc14": "インストーラーのパブリッシャーがOrcaと一致しないため、更新を停止しました。このダウンロードをインストールしないでください。修正版は公式リリースを確認してください。", + "c9ff9b9ec2": "公式リリースを確認", + "a05992a26b": "署名チェックを実行できませんでした。通常はウイルス対策ソフトウェアがブロックしたためです。ダウンロードを再試行するか、公式リリースからインストーラーを取得してください。", + "5194358929": "詳細を非表示", + "8bc9e17d8f": "詳細を表示", + "8cf17b10af": "ローカルビルドエラー", + "a4650b0dc4": "ローカルビルドを使用できません", + "b1e390250d": "ローカルビルドの切り替えを完了できませんでした。", + "d29740d175": "選択したビルドを使用できませんでした。", + "37d45c9ec1": "別のビルドを選択" }, "WorktreeJumpPalette": { "ac037cfac2": "移動", @@ -2779,11 +2787,11 @@ "removeWorkspaceButton": "ワークスペースを削除" }, "TerminalRemoteRuntimeReconnectBanner": { - "retryingTitle": "Reconnecting to remote runtime", - "disconnectedTitle": "Remote runtime disconnected", - "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.", - "disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.", - "reconnectButton": "Reconnect" + "retryingTitle": "リモートランタイムに再接続中", + "disconnectedTitle": "リモートランタイムが切断されました", + "retryingBody": "Orcaは最大1分間再試行します。接続が復元されると、このターミナルが再開されます。", + "disconnectedBody": "自動再試行が停止しました。このターミナルセッションを再開するには再接続してください。", + "reconnectButton": "再接続" } } }, @@ -3062,10 +3070,10 @@ "59f178fe11": "{{value0}}、デーモンに到達できません", "21cacb16d1": "· リモート", "73a3fd68a9": "repo を折りたたむ", - "b12e31dfcb": "repo を拡張する", + "b12e31dfcb": "repo を展開する", "d659d71d2d": "ワークスペース {{value0}} を再開します", "bbcd9b7b85": "ワークスペースを折りたたむ", - "c4a8968bdd": "ワークスペースを拡張する", + "c4a8968bdd": "ワークスペースを展開する", "b10695d6ce": "セッションを強制終了する", "288a4dd177": "Orca", "53dd5560ae": "Orca を折りたたむ", @@ -3078,9 +3086,9 @@ "a82253b458": "ワークスペースを削除します。", "946724a70a": "メインワークスペースは削除できません。", "16bc3c998a": "ワークスペース {{value0}} を削除", - "0f9e50eb07": "他の", + "0f9e50eb07": "その他", "d406915b78": "レンダラー", - "81cd37af99": "主要", + "81cd37af99": "メイン", "fa6d36758d": "セッション {{value0}} を強制終了", "b8f4a2c1d0e3": "{{value0}} 件の孤立", "c7e3b1a0d9f2": "孤立した terminal {{value0}} 件を終了", @@ -3100,8 +3108,8 @@ "3d0128b105": "接続されています", "fd9a3c600e": "エラー", "fbb3f9f05e": "競合", - "95e4ff5b4b": "押す", - "63a2b965f6": "引っ張る", + "95e4ff5b4b": "プッシュ中", + "63a2b965f6": "プル中", "remote_server": "リモートサーバー", "runtime_checking": "確認中", "runtime_online": "接続済み", @@ -3117,7 +3125,7 @@ "d1e1a7a6bf": "リソースマネージャー", "24ac89df1a": "リモートホスト", "5e59007df4": "Kimi 使用量", - "8c86cd77b0": "OpenCode Go の使用法", + "8c86cd77b0": "OpenCode Go の使用量", "c1df0d67ec": "Gemini 使用量", "c0909c686e": "Codex 使用量", "3885eb74d8": "Claude 使用量", @@ -3143,7 +3151,7 @@ "3dd7ddfae1": "Claude の詳細とアカウント切り替えを開く", "59c6e7b4e0": "表示されているセッションが再開されます。他のものは、ワークツリーがアクティブになると再起動します。", "c676918adc": "システムのデフォルト", - "3325d996cb": "リフレッシュレートの制限", + "3325d996cb": "レート制限をリセット", "fda8146810": "Kimi の使用状況の詳細を開く", "629251f4b6": "OpenCode Go の使用状況の詳細を開く", "d2375976eb": "Gemini の使用状況の詳細を開く", @@ -3177,7 +3185,7 @@ "caa0f39811": "サポート:", "97957ad3a3": "AI プロバイダー アカウントに接続すると、その使用状況をリアルタイムで確認し、アカウント間を簡単に切り替えることができます。", "9a542f46c7": "ステータスバーから隠す", - "84c3b15dca": "Agent の使用制限", + "84c3b15dca": "エージェントの使用量制限", "d663430cf9": "使用状況の追跡を設定" }, "UpdateStatusSegment": { @@ -3192,7 +3200,7 @@ }, "WorkspaceSpaceCompactPanel": { "a471aa9c24": "更新されました", - "9be86c46a0": "無料可能", + "9be86c46a0": "回収可能", "f4d2651498": "スキャン済み", "6a5dc3c61a": "レビュー", "c361440dc0": "ベータ", @@ -3201,7 +3209,7 @@ "f5e1a84d79": "更新", "2af2174d6d": "キャンセル", "5691353a21": "停止中", - "2837dc7c72": "キャンセルする", + "2837dc7c72": "キャンセル中", "0583c806ac": "ワークスペースのディスク使用量はスキャンされません。", "39786e3b73": "ワークスペースのサイズをスキャンしています。", "bef4dc0457": "{{value0}} は回収可能ですが、{{value1}} は利用できません", @@ -3211,9 +3219,9 @@ "2965415393": "強制削除に失敗しました", "e031e93219": "一致するワークスペースがありません。", "a02d84d2d2": "ワークスペースのスキャン。このページから離れても構いません。", - "be37293b10": "州", + "be37293b10": "状態", "33aef3e9cc": "サイズ", - "81f14d9924": "Repos", + "81f14d9924": "リポジトリ", "e4aebea158": "ワークスペース", "1d0f8300d1": "表示されている削除可能なワークスペースを選択します", "697d60c456": "表示されている選択範囲をクリアします", @@ -3249,15 +3257,15 @@ "0bc756efaf": "Git の変更", "e9528a89b3": "ターミナル", "a8d9e0de79": "エージェント", - "d384a4ce9f": "決定の削除", + "d384a4ce9f": "削除の判定", "7d7745bb8f": "削除できます", "720870a18e": "保持: リンク済み", "cbc343a7a8": "保持: 使用中", "2055bc6a5a": "保持: 未保存の編集内容", "ec7b076a75": "保持: git はチェックされていません", "7ab8d7e2d7": "保持: 変更されたファイル", - "7f7895514e": "維持: アクティブ", - "2b501ee391": "キープ: メイン", + "7f7895514e": "保持: アクティブ", + "2b501ee391": "保持: メイン", "39801484e0": "失敗した", "33653dbac2": "削除中", "52b629eb84": "更新されました", @@ -3350,35 +3358,35 @@ "gotIt": "Got it" }, "UsageRosterPanel": { - "title": "Usage", - "openDetails": "Open usage details", - "notSignedIn": "not signed in", - "allAgents": "all agents", - "usageDetails": "Usage details & history", - "loadingUsage": "Loading usage…", - "usageUnavailable": "Usage unavailable", - "noUsageData": "No usage data", - "detailed": "Detailed", - "compact": "Compact", - "footerDetailAria": "Usage footer detail", + "title": "使用量", + "openDetails": "使用量の詳細を開く", + "notSignedIn": "未サインイン", + "allAgents": "全エージェント", + "usageDetails": "使用量の詳細と履歴", + "loadingUsage": "使用量を読み込み中…", + "usageUnavailable": "使用量を取得できません", + "noUsageData": "使用量データなし", + "detailed": "詳細", + "compact": "コンパクト", + "footerDetailAria": "使用量フッターの詳細", "detailedTooltip": "バー・ラベル・パーセントを含む完全な使用状況", "compactTooltip": "凝縮された使用状況: 最も狭いウィンドウのみ" }, "RemoteServerUpdateStatusSegment": { - "updating": "Updating {{value0}}/{{value1}}", - "updatingTooltip": "Remote Orca Server updates are in progress", - "failed": "{{value0}} server updates failed", - "failedTooltip": "Open Remote Orca Server updates to review and retry", - "updated": "{{value0}} servers updated", - "updatedTooltip": "Remote Orca Server updates completed", - "failedOne": "1 server update failed", - "updatedOne": "1 server updated" + "updating": "{{value0}}/{{value1}}を更新中", + "updatingTooltip": "リモートOrcaサーバーの更新が進行中", + "failed": "{{value0}}台のサーバー更新に失敗", + "failedTooltip": "リモートOrcaサーバーの更新を開いて確認し、再試行してください", + "updated": "{{value0}}台のサーバーを更新完了", + "updatedTooltip": "リモートOrcaサーバーの更新が完了しました", + "failedOne": "1台のサーバー更新に失敗", + "updatedOne": "1台のサーバーを更新完了" }, "resource": { "memory": { "metric": { - "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", - "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." + "workingSetDescription": "合計ワーキングセット(WS)。共有ページが複数のプロセスに表示されることがあります。", + "rssDescription": "合計レジデントセットサイズ(RSS)。共有またはエイリアスされたページが複数のプロセスに表示されることがあります。" } } }, @@ -4837,8 +4845,8 @@ "1a2b3c4d5e": "Removed {{value0}}", "2b3c4d5e6f": "Failed to remove host", "3c4d5e6f7a": "Remove {{value0}}?", - "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", - "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", + "4d5e6f7a8b": "これにより、このサーバーを削除できるOrcaサーバー設定が開きます。", + "5e6f7a8b9c": "このサーバーはこのコンピューターからSSHホストとその資格情報を削除します。リモートファイルは削除されません。", "6f7a8b9c0d": "キャンセル", "7a8b9c0d1e": "Open settings", "8b9c0d1e2f": "ホストを削除", @@ -4854,15 +4862,15 @@ }, "HostRenameDialog": { "1a2b3c4d5e": "Rename host", - "2b3c4d5e6f": "This label is shown only on this computer. Leave it blank to use the default name.", - "3c4d5e6f7a": "Display name", - "4d5e6f7a8b": "Reset to default", + "2b3c4d5e6f": "このラベルはこのコンピューターにのみ表示されます。デフォルト名を使用する場合は空のままにしてください。", + "3c4d5e6f7a": "表示名", + "4d5e6f7a8b": "デフォルトにリセット", "5e6f7a8b9c": "キャンセル", "6f7a8b9c0d": "保存" }, "HostSectionHeaderMenu": { - "5b8b4b6a01": "Update server required", - "9b3c1d2e44": "Update client required", + "5b8b4b6a01": "サーバーの更新が必要", + "9b3c1d2e44": "クライアントの更新が必要", "2c29e2de68": "Connection failed", "bf07aee59e": "切断に失敗しました", "7f1a2b3c4d": "{{value0}} is reachable", @@ -5105,7 +5113,7 @@ "e4a28e8894": "Codex は、{{value0}} ログインには新たなサインインが必要であると報告しました。新規 Codex セッションを開始する前に、再度サインインします。", "75ca9b718e": "Codex は、アクティブなアカウントには新たにサインインする必要があると報告しました。新規 Codex セッションを開始する前に、再認証してください。", "b11078a9c2": "wsl", - "350b2a1aa7": "Use your current", + "350b2a1aa7": "現在の", "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。", "2f24f244a4": "MiniMax Cookie が必要です。", "8e6f0cb1d8": "MiniMax Cookie は保存されませんでした。", @@ -5231,13 +5239,13 @@ "5f986a9b92": "デフォルトとして設定", "d7625cf8b2": "デフォルト agent", "cfb3f35775": "引数", - "6f99bf5dd0": "No default arguments", + "6f99bf5dd0": "デフォルト引数なし", "8fbe1f37c1": "環境", - "2d133152fa": "No default environment", - "agentPermissions": "Agent Permissions", - "agentPermissionsInfo": "Agent permissions info", - "agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.", - "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", + "2d133152fa": "デフォルト環境なし", + "agentPermissions": "エージェント権限", + "agentPermissionsInfo": "エージェント権限情報", + "agentPermissionsTooltip": "カスタムエージェント引数はモード切り替え時も変更されません。", + "agentPermissionsDescription": "Orcaが権限プロンプトの少ないエージェントを起動するか、手動チェックを含むエージェントを起動するかを選択します。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手動", "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", @@ -5603,7 +5611,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "他のローカル アプリを制御するスクリプトの Apple Events。", "e119f0d66b": "オートメーション", - "7ca17b62c8": "プロジェクト、worktree、またはシンボリックリンクされたファイルが macOS の保護フォルダーに触れる場合に推奨されます。", + "7ca17b62c8": "Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。", "c566bca278": "フルディスクアクセス", "9f35980756": "キーストローク インジェクション、ウィンドウ コントロール、UI 自動化ツール。", "5b2f22ca2d": "アクセシビリティ", @@ -5655,15 +5663,15 @@ "defaultViewNative": "Chat UI" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.", - "copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.", - "toggleLabel": "Toggle Agent Dashboard", - "modeLabel": "Open as", - "modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.", - "modeAriaLabel": "Agent Dashboard open mode", - "modeInWindow": "In-window", - "modePopout": "Pop-out" + "title": "エージェント ダッシュボード", + "description": "ワークツリーエージェントを監視するカンバンボード。ウィンドウ内またはポップアウトで表示できます。", + "copy": "左サイドバーにエージェント ダッシュボードのエントリを追加します。開くと、注意中・作業中・アイドルのエージェントを監視し、ライブターミナルにジャンプできます。", + "toggleLabel": "エージェント ダッシュボードの切り替え", + "modeLabel": "開き方", + "modeCopy": "ダッシュボードをサイドバー横のウィンドウ内ボードまたは別のポップアウトウィンドウとして表示します。", + "modeAriaLabel": "エージェント ダッシュボードの開き方", + "modeInWindow": "ウィンドウ内", + "modePopout": "ポップアウト" } }, "FloatingWorkspacePane": { @@ -6454,11 +6462,11 @@ "viewingHost": "Viewing host", "currentSetup": "現在", "hostSetupStateReady": "準備完了", - "hostSetupStateNotSetUp": "Not set up", - "hostSetupStateSettingUp": "Setting up", - "hostSetupStateError": "Error", + "hostSetupStateNotSetUp": "未設定", + "hostSetupStateSettingUp": "設定中", + "hostSetupStateError": "エラー", "hostSetupStateUnsupported": "未対応", - "setupPathPending": "Path pending", + "setupPathPending": "パス保留中", "openSetup": "オープン", "removeSetup": "削除", "hostSetupBlockedVersion": "Orca server version is incompatible", @@ -6477,22 +6485,22 @@ "setupHost": "インポート", "cloningHost": "クローン中...", "cloneHost": "クローン", - "creatingPendingSetup": "Creating...", - "createPendingSetup": "Track setup", - "hostSetupCheckingCapability": "Checking host capabilities", - "hostAvailability": "Host availability", - "hostAvailabilityHelp": "Add this same project on another connected host.", - "addToAnotherHost": "Add to another host", - "addProjectHost": "Add project to host", - "addProjectHostHelp": "Choose where this project should also be available.", + "creatingPendingSetup": "作成中...", + "createPendingSetup": "セットアップを追跡", + "hostSetupCheckingCapability": "ホスト機能を確認中", + "hostAvailability": "ホストの可用性", + "hostAvailabilityHelp": "別の接続ホストに同じプロジェクトを追加します。", + "addToAnotherHost": "別のホストに追加", + "addProjectHost": "ホストにプロジェクトを追加", + "addProjectHostHelp": "このプロジェクトをどこで利用可能にするかを選択します。", "closeHostSetup": "閉じる", "setupHostLabel": "ホスト", "browseFolder": "Browse folder", "browseFolderHelp": "Use an existing checkout or folder on this host.", "otherWaysToAdd": "Other ways to add", - "cloneFromUrl": "Clone from URL", - "cloneFromUrlHelp": "Clone this repository onto the selected host.", - "addPlannedHost": "Add host placeholder", + "cloneFromUrl": "URLからクローン", + "cloneFromUrlHelp": "このリポジトリを選択したホストにクローンします。", + "addPlannedHost": "ホストプレースホルダーを追加", "addPlannedHostHelp": "このホストを記憶し、後でプロジェクトの追加を完了します。", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", @@ -7895,8 +7903,8 @@ "grok": "grok" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out." + "title": "エージェント ダッシュボード", + "description": "ワークツリーエージェントを監視するカンバンボード。ウィンドウ内またはポップアウトで表示できます。" } } }, @@ -9020,16 +9028,16 @@ "a1093a06c7": "Checking Jira access before showing setup actions.", "9fa04a032e": "{{value0}} site{{value1}} connected", "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", - "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", - "1a12e33fe5": "Add Linear access", - "622c224082": "Add workspace access", - "eae4a9f16b": "Add Linear access to browse and link issues.", - "fe9231215b": "Checking Linear access before showing setup actions.", - "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "6224fe9d34": "接続された各Linearワークスペースには、アクティブランタイムによって保存された1つのキーがあります。フルアクセスキーは、キー所有者がアクセスできるすべてのチームをカバーできます。制限付きキーはいつでも置き換えることができます。", + "1a12e33fe5": "Linearアクセスを追加", + "622c224082": "ワークスペースアクセスを追加", + "eae4a9f16b": "Linearアクセスを追加して、イシューを閲覧およびリンクします。", + "fe9231215b": "セットアップアクションを表示する前にLinearアクセスを確認中。", + "e1f5e6424c": "{{value0}}ワークスペース{{value1}}接続済み", "disconnect_all": "切断", - "account_scope_prefix": "Account scope", - "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", - "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." + "account_scope_prefix": "アカウントスコープ", + "2d60ec7921": "APIトークンを使用してJira Cloudサイトに接続するか、パーソナルアクセストークンまたはユーザー名とパスワードを使用してセルフホストJiraに接続します。資格情報は選択されたリモートランタイムに送信され、ランタイムがサポートする暗号化で保存されます。", + "977e360b71": "APIトークンを使用してJira Cloudサイトに接続するか、パーソナルアクセストークンまたはユーザー名とパスワードを使用してセルフホストJiraに接続します。資格情報はローカルに保存され、ローカルランタイムストレージがサポートする場合は暗号化されます。" } } } @@ -9150,38 +9158,38 @@ "off": "オフ" }, "DefaultWindowsProjectRuntimeSetting": { - "defaultRuntime": "Default project runtime", + "defaultRuntime": "デフォルトのプロジェクトランタイム", "windows": "Windows", "wsl": "WSL", - "selectDistro": "Select distro", - "windowsDescription": "Projects inherit Windows unless a project overrides it.", - "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", - "distroRequired": "Choose a WSL distro before projects can inherit WSL.", - "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + "selectDistro": "ディストリビューションを選択", + "windowsDescription": "プロジェクトは、プロジェクトでオーバーライドしない限りWindowsを継承します。", + "wslUnavailable": "WSLは利用できません。WSLを継承するプロジェクトは修復が必要です。", + "distroRequired": "プロジェクトがWSLを継承する前にWSLディストリビューションを選択してください。", + "wslDescription": "プロジェクトは、プロジェクトでオーバーライドしない限りWSL経由で{{value0}}を継承します。" }, "ProjectWindowsRuntimeSetting": { - "projectRuntime": "Project runtime", - "defaultRuntime": "Default ({{value0}})", + "projectRuntime": "プロジェクトランタイム", + "defaultRuntime": "デフォルト ({{value0}})", "windows": "Windows", "wsl": "WSL", - "selectDistro": "Select distro", - "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", - "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", - "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", - "distroRequired": "Choose a WSL distro or switch this project to Windows.", - "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", - "projectWsl": "This project runs in {{value0}} via WSL.", - "inheritedWindows": "No project override. General settings select Windows.", - "projectWindows": "This project runs on Windows.", + "selectDistro": "ディストリビューションを選択", + "runtimeChangeHelp": "ランタイムの変更は、このプロジェクトの新しいターミナル、エージェントチェック、スキル検索に適用されます。既存のターミナルは現在のランタイムを維持します。", + "wslUnavailable": "WSLは利用できません。このプロジェクトをWindowsに切り替えるか、WSLを修復してください。", + "distroMissing": "{{value0}}はWSLにインストールされていません。インストールされたディストリビューションを選択するか、このプロジェクトをWindowsに切り替えてください。", + "distroRequired": "WSLディストリビューションを選択するか、このプロジェクトをWindowsに切り替えてください。", + "inheritedWsl": "プロジェクトオーバーライドなし。一般設定でWSL経由で{{value0}}を選択しています。", + "projectWsl": "このプロジェクトはWSL経由で{{value0}}で実行されます。", + "inheritedWindows": "プロジェクトオーバーライドなし。一般設定でWindowsを選択しています。", + "projectWindows": "このプロジェクトはWindowsで実行されます。", "liveTerminalSingular": "{{count}} 個の実行中ターミナル", "liveTerminalPlural": "{{count}} 個の実行中ターミナル", "activeTaskSingular": "{{count}} 件のアクティブなタスク", "activeTaskPlural": "{{count}} 件のアクティブなタスク", "runtimeSessionJoin": "{{value0}}と{{value1}}", "runtimeSessionWarning": "{{value0}}は現在のランタイムで実行を続けます。続行する前にタスクの完了を待つか、ターミナルを再起動してください。", - "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "pendingRuntimeChange": "ランタイムの変更保留中。新しいプロジェクト作業は、適用後に選択されたランタイムを使用します。", "cancel": "キャンセル", - "applyRuntimeChange": "Apply runtime change" + "applyRuntimeChange": "ランタイムの変更を適用" }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "DO_NOT_TRACK=1 が設定されています — 診断ファイルの作成と送信は無効です。", @@ -9194,15 +9202,15 @@ "eb72c52c28": "Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels.", "70b5d25583": "{{value0}} shortcut", "287e07ddde": "Modified", - "3c83cd7d1c": "Disabled", - "07939d084e": "Reset {{value0}} to default", - "9b02917027": "Reset to default", - "a799f90f82": "Disable {{value0}}", + "3c83cd7d1c": "無効", + "07939d084e": "{{value0}}をデフォルトにリセット", + "9b02917027": "デフォルトにリセット", + "a799f90f82": "{{value0}}を無効にする", "25e6e76618": "ショートカットを無効にする", "035a822ef0": "ショートカットを追加", - "a0e2ef0e61": "Add another shortcut for {{value0}}", + "a0e2ef0e61": "{{value0}}の別のショートカットを追加", "245c83af24": "別のショートカットを追加", - "482a60225d": "Enable {{value0}}", + "482a60225d": "{{value0}}を有効にする", "6287677c37": "有効にする", "01481b964c": "Add shortcut for {{value0}}" }, @@ -9218,7 +9226,7 @@ }, "ShortcutRemoveButton": { "9e29aff18b": "{{value0}} ショートカット {{value1}} を削除", - "2a9588b1c2": "Remove this binding" + "2a9588b1c2": "このバインディングを削除" }, "BrowserLocalhostWorktreeLabelsSetting": { "8ac8c3ad19": "ローカルホストのワークツリーのラベル", @@ -9255,32 +9263,32 @@ "DevToolsPane": { "nativeActionLayoutCheck": "Native action layout check", "secondary": "Secondary", - "secondaryActionClicked": "Secondary action clicked", - "primaryAction": "Primary action", - "primaryActionClicked": "Primary action clicked", - "branchHasChanges": "branch has changes", - "viewChangesClicked": "View Changes clicked", - "forceDeleteClicked": "Force Delete clicked", - "devOnlyCallback": "Dev-only callback.", - "infoToast": "Info toast", + "secondaryActionClicked": "セカンダリアクションがクリックされました", + "primaryAction": "プライマリアクション", + "primaryActionClicked": "プライマリアクションがクリックされました", + "branchHasChanges": "ブランチに変更があります", + "viewChangesClicked": "変更を表示がクリックされました", + "forceDeleteClicked": "強制削除がクリックされました", + "devOnlyCallback": "開発専用コールバック。", + "infoToast": "情報トースト", "infoToastDescription": "明示的なアクションのない長い情報コピー。", - "localCanaryBehind": "Local canary is behind origin/canary", - "successToast": "Success toast", + "localCanaryBehind": "ローカルカナリーがorigin/canaryより遅れています", + "successToast": "成功トースト", "successToastDescription": "短い確認状態。", - "settingsSaved": "Settings saved", + "settingsSaved": "設定が保存されました", "shortSuccessCopy": "短い成功コピー。", - "errorToast": "Error toast", + "errorToast": "エラートースト", "errorToastDescription": "復元アクションのない長いエラーコピー。", - "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", - "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Sonner アクションとキャンセルスロットを使用。", - "deleteFailureToast": "Delete failure toast", + "failedToSyncWorkspaceMetadata": "ワークスペースメタデータの同期に失敗しました", + "nativeActionToast": "ネイティブアクショントースト", + "nativeActionToastDescription": "Sonnerアクションとキャンセルスロットを使用。", + "deleteFailureToast": "削除失敗トースト", "deleteFailureToastDescription": "表示と強制削除を含むカスタムフッター。", - "behindBaseRefToast": "Behind base ref toast", + "behindBaseRefToast": "ベースリフの後ろトースト", "behindBaseRefToastDescription": "インラインの設定リンクとフッターアクション付きの永続プロンプト。", - "notificationPlayground": "Notification playground", - "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only", + "notificationPlayground": "通知プレイグラウンド", + "notificationPlaygroundDescription": "トーストレイアウト、復元アクション、長文コピーの折り返しを確認するための開発専用トリガー。", + "devOnly": "開発専用", "orcaCloud": "Orca Cloud", "orcaCloudDescription": "第一方クラウドサインインの開発者限定プレビュー。本番環境では非表示。開発環境では、ORCA_CLOUD_API_URLとORCA_CLOUD_CLIENT_IDを設定するとサイドバーのアカウント切替にも表示されます。", "orcaCloudStatus": "ステータス", @@ -9505,62 +9513,62 @@ "checking": "Checking paired servers…", "empty": "No paired Remote Orca Servers.", "checkAgain": "Check again", - "updating": "Updating servers…", - "updateAll": "Update {{value0}} servers", - "noUpdates": "No updates available", - "updateOne": "Update server", - "downloadProgress": "{{value0}} download progress", - "liveTabOne": "1 live tab", - "liveTabs": "{{value0}} live tabs", - "livePaneOne": "1 live pane", - "livePanes": "{{value0}} live panes" + "updating": "サーバーを更新中…", + "updateAll": "{{value0}}台のサーバーを更新", + "noUpdates": "利用可能な更新なし", + "updateOne": "サーバーを更新", + "downloadProgress": "{{value0}} ダウンロード進捗", + "liveTabOne": "1つのライブタブ", + "liveTabs": "{{value0}}つのライブタブ", + "livePaneOne": "1つのライブペイン", + "livePanes": "{{value0}}つのライブペイン" }, "RemoteServerUpdateStatus": { - "checking": "Checking…", - "available": "Update available", - "current": "Up to date", - "manual": "Manual update", - "offline": "Offline", - "queued": "Queued", - "checkingUpdate": "Checking update…", - "downloading": "Downloading…", - "restarting": "Restarting…", - "updated": "Updated", - "failed": "Update failed", - "serviceManagerHelp": "Update Orca through the service manager that starts this server.", - "unpackedHelp": "Development builds must be updated from their source checkout.", - "legacyHelp": "Update this server manually once to enable remote updates." + "checking": "確認中…", + "available": "更新が利用可能", + "current": "最新", + "manual": "手動更新", + "offline": "オフライン", + "queued": "キュー待ち", + "checkingUpdate": "更新を確認中…", + "downloading": "ダウンロード中…", + "restarting": "再起動中…", + "updated": "更新済み", + "failed": "更新に失敗", + "serviceManagerHelp": "このサーバーを開始するサービスマネージャーを通じてOrcaを更新してください。", + "unpackedHelp": "開発ビルドはソースチェックアウトから更新する必要があります。", + "legacyHelp": "リモート更新を有効にするには、このサーバーを手動で一度更新してください。" }, "PluginConsentDialog": { - "workerTrust": "Background worker — runs its own process", - "instructionalTrust": "Instructional content — runs later under user or agent authority", - "declarativeTrust": "Declarative content — no plugin code", - "panelTrust": "Panel or host-integrated content — no worker process", - "trustShortWorker": "Worker", - "trustShortInstructional": "Instructional", - "trustShortPanel": "Panel", - "trustShortDeclarative": "Declarative", - "reviewTitle": "Review plugin", - "title": "Review permissions", - "mixedTitle": "Review access and content", - "instructionalTitle": "Review plugin content", + "workerTrust": "バックグラウンドワーカー — 自身のプロセスを実行", + "instructionalTrust": "インストラクショナルコンテンツ — ユーザーまたはエージェントの権限で後から実行", + "declarativeTrust": "宣言型コンテンツ — プラグインコードなし", + "panelTrust": "パネルまたはホスト統合コンテンツ — ワーカープロセスなし", + "trustShortWorker": "ワーカー", + "trustShortInstructional": "インストラクショナル", + "trustShortPanel": "パネル", + "trustShortDeclarative": "宣言型", + "reviewTitle": "プラグインを確認", + "title": "権限を確認", + "mixedTitle": "アクセスとコンテンツを確認", + "instructionalTitle": "プラグインコンテンツを確認", "subtitle": "{{value0}} v{{value1}} · {{value2}}", - "reconsent": "Permissions, the worker trust tier, or instructional content changed since you last reviewed this plugin. Review it again before it can run.", - "capabilities": "This plugin can", - "warning": "These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes.", - "instructionalWarning": "This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.", - "panelWarning": "These permissions limit how the plugin uses Orca's API. This plugin has no background worker.", - "declarativeWarning": "This plugin contributes validated content only. It does not run a background worker or receive access to Orca's API.", - "keepDisabled": "Keep Disabled", - "enable": "Enable plugin", + "reconsent": "前回このプラグインを確認した以降に、権限、ワーカー信頼階層、またはインストラクショナルコンテンツが変更されました。実行する前に再確認してください。", + "capabilities": "このプラグインは", + "warning": "これらの権限は、プラグインがOrca APIを使用する方法を制限します。ワーカーは引き続きコンピューターで通常のプロセスとして実行され、ファイル、ネットワーク、その他のプロセスへの完全なアクセス権を持ちます。", + "instructionalWarning": "このプラグインにはワーカープロセスがありません。インストラクショナルコンテンツは、ユーザーまたはエージェントが使用した場合でもアクションを引き起こす可能性があります。有効にする前に、以下の指示とコマンドを確認してください。", + "panelWarning": "これらの権限は、プラグインがOrca APIを使用する方法を制限します。このプラグインにはバックグラウンドワーカーがありません。", + "declarativeWarning": "このプラグインは検証されたコンテンツのみを提供します。バックグラウンドワーカーを実行せず、Orca APIへのアクセスを受け取りません。", + "keepDisabled": "無効のまま維持", + "enable": "プラグインを有効にする", "capability": { - "workspaceRead": "Read the name, branch, and terminal list of your focused worktree", - "terminalSend": "Type text into a terminal you can see (always a specific terminal)", - "notificationsShow": "Show desktop notifications labeled with the plugin name", - "storage": "Store data in the plugin's own storage folder", - "secrets": "Store and read secrets in the plugin's own encrypted vault", - "eventsSubscribe": "Get notified when worktrees are created or removed and when agent status changes", - "settingsOwn": "Read and change the plugin's own settings" + "workspaceRead": "フォーカスされたワークツリーの名前、ブランチ、ターミナルリストを読み取る", + "terminalSend": "見えるターミナルにテキストを入力(常に特定のターミナル)", + "notificationsShow": "プラグイン名がラベル付けされたデスクトップ通知を表示", + "storage": "プラグインの独自ストレージフォルダーにデータを保存", + "secrets": "プラグインの独自暗号化保管庫にシークレットを保存および読み取り", + "eventsSubscribe": "ワークツリーが作成または削除され、エージェントステータスが変更されたときに通知を受ける", + "settingsOwn": "プラグインの独自設定を読み取りおよび変更" }, "decisionFailed": "Could not save the permission decision. Try again." }, @@ -9593,192 +9601,192 @@ "title": "Install plugin", "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", "source": "Install source", - "localTab": "Local folder", + "localTab": "ローカルフォルダー", "gitTab": "Git URL", - "localLabel": "Plugin folder path", + "localLabel": "プラグインフォルダーパス", "localPlaceholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "localHelp": "Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.", - "gitLabel": "Repository URL with #ref", + "localHelp": "このコンピューターのorca-plugin.jsonを含むフォルダーへのフルパス。パスは入力された通りに正確に使用されます。", + "gitLabel": "#ref付きリポジトリURL", "gitPlaceholder": "https://git.example/acme/orca-notes#v0.1.0", - "gitHelp": "Append an explicit #ref — a tag or commit — so the install is pinned. Works with GitHub, GitLab, and any git host.", - "cancel": "Cancel", - "installing": "Installing…", - "install": "Install", - "installFailed": "Plugin installation failed. Check the source and try again." + "gitHelp": "インストールを固定するために明示的な#ref — タグまたはコミット — を追加。GitHub、GitLab、およびすべてのgitホストで動作します。", + "cancel": "キャンセル", + "installing": "インストール中…", + "install": "インストール", + "installFailed": "プラグインのインストールに失敗しました。ソースを確認して再試行してください。" }, "PluginKeybindingConsentPreview": { - "heading": "Keyboard shortcuts", - "worktree": "Runs only while a workspace is active.", - "global": "Runs in the app without requiring an active workspace.", - "shadows": "Replaces: {{value0}}" + "heading": "キーボードショートカット", + "worktree": "ワークスペースがアクティブな間のみ実行。", + "global": "アクティブワークスペースなしでアプリで実行。", + "shadows": "置換: {{value0}}" }, "PluginMarketplaceBrowser": { - "loadFailed": "Could not load marketplace plugins.", - "refreshFailed": "Could not refresh marketplaces. Cached listings remain available.", - "previewFailed": "Could not prepare this plugin for review. Refresh the marketplace and try again.", - "installFailed": "Could not install this plugin. The reviewed source may have changed.", - "manageSources": "Manage sources", - "refreshing": "Refreshing…", - "refresh": "Refresh", - "noInstalledTitle": "No plugins installed", - "noInstalled": "Plugins you install appear here.", - "loading": "Loading marketplace plugins…", - "tryAgain": "Try again", - "noSourcesTitle": "No marketplaces configured", - "noSources": "Add an official, community, or private Git marketplace to browse plugins.", - "addSource": "Add marketplace", - "noResultsTitle": "No matching plugins", - "noResults": "No marketplace plugins match this search.", - "clearSearch": "Clear search", - "emptyTitle": "Nothing listed yet", - "empty": "The configured marketplaces do not list any plugins." + "loadFailed": "マーケットプレイスプラグインをロードできませんでした。", + "refreshFailed": "マーケットプレイスを更新できませんでした。キャッシュされたリストは引き続き利用可能です。", + "previewFailed": "このプラグインをレビュー用に準備できませんでした。マーケットプレイスを更新して再試行してください。", + "installFailed": "このプラグインをインストールできませんでした。レビューされたソースが変更された可能性があります。", + "manageSources": "ソースを管理", + "refreshing": "更新中…", + "refresh": "更新", + "noInstalledTitle": "インストールされたプラグインなし", + "noInstalled": "インストールしたプラグインはここに表示されます。", + "loading": "マーケットプレイスプラグインを読み込み中…", + "tryAgain": "再試行", + "noSourcesTitle": "マーケットプレイス未設定", + "noSources": "公式、コミュニティ、またはプライベートGitマーケットプレイスを追加してプラグインを参照。", + "addSource": "マーケットプレイスを追加", + "noResultsTitle": "一致するプラグインなし", + "noResults": "この検索に一致するマーケットプレイスプラグインはありません。", + "clearSearch": "検索をクリア", + "emptyTitle": "まだリストなし", + "empty": "設定されたマーケットプレイスにはプラグインがリストされていません。" }, "PluginMarketplaceListingRow": { - "official": "Official", - "installed": "Installed", - "noDescription": "No description provided.", - "blocked": "Blocked by Orca's safety list: {{value0}}", - "blockedAction": "Blocked", - "checkUpdate": "Check for update", - "install": "Install" + "official": "公式", + "installed": "インストール済み", + "noDescription": "説明が提供されていません。", + "blocked": "Orcaの安全リストによりブロック: {{value0}}", + "blockedAction": "ブロック済み", + "checkUpdate": "更新を確認", + "install": "インストール" }, "PluginMarketplacePreviewDialog": { - "languagePacksOne": "1 language pack", - "languagePacks": "{{value0}} language packs", - "commandsOne": "1 command", - "commands": "{{value0}} commands", - "keybindingsOne": "1 keyboard shortcut", - "keybindings": "{{value0}} keyboard shortcuts", - "vmRecipesOne": "1 VM recipe", - "vmRecipes": "{{value0}} VM recipes", - "panelsOne": "1 panel", - "panels": "{{value0}} panels", - "eventsOne": "1 event subscription", - "events": "{{value0}} event subscriptions", - "worker": "Background worker", + "languagePacksOne": "1つの言語パック", + "languagePacks": "{{value0}}つの言語パック", + "commandsOne": "1つのコマンド", + "commands": "{{value0}}つのコマンド", + "keybindingsOne": "1つのキーボードショートカット", + "keybindings": "{{value0}}つのキーボードショートカット", + "vmRecipesOne": "1つのVMレシピ", + "vmRecipes": "{{value0}}つのVMレシピ", + "panelsOne": "1つのパネル", + "panels": "{{value0}}つのパネル", + "eventsOne": "1つのイベントサブスクリプション", + "events": "{{value0}}つのイベントサブスクリプション", + "worker": "バックグラウンドワーカー", "versionLine": "v{{value0}} · {{value1}}", - "includes": "Includes", - "noContributions": "Manifest metadata only", - "capabilities": "Requested access", - "workerWarning": "Capabilities limit how this plugin uses Orca's API. Its worker still runs as a normal process on this computer with full access to your files, network, and other processes.", - "blocked": "Orca's safety list blocks this plugin: {{value0}}", - "current": "This exact plugin content is already installed.", - "close": "Close", - "cancel": "Cancel", - "update": "Update plugin", - "install": "Install plugin" + "includes": "含む", + "noContributions": "マニフェストメタデータのみ", + "capabilities": "要求されたアクセス", + "workerWarning": "機能はこのプラグインがOrca APIを使用する方法を制限します。ワーカーは引き続きコンピューターで通常のプロセスとして実行され、ファイル、ネットワーク、その他のプロセスへの完全なアクセス権を持ちます。", + "blocked": "Orcaの安全リストがこのプラグインをブロック: {{value0}}", + "current": "このプラグインコンテンツはすでにインストールされています。", + "close": "閉じる", + "cancel": "キャンセル", + "update": "プラグインを更新", + "install": "プラグインをインストール" }, "PluginMarketplaceSourceDialog": { - "addFailed": "Could not add this marketplace. Check the Git URL, ref, and your Git credentials.", - "refreshFailed": "Could not refresh this marketplace. Its last valid cached index is still available.", - "removeFailed": "Could not remove this marketplace.", - "title": "Marketplace sources", - "description": "Marketplaces are pinned Git repositories. Orca uses your existing system Git credentials for private repositories.", + "addFailed": "このマーケットプレイスを追加できませんでした。Git URL、ref、Git資格情報を確認してください。", + "refreshFailed": "このマーケットプレイスを更新できませんでした。最後に有効なキャッシュされたインデックスは引き続き利用可能です。", + "removeFailed": "このマーケットプレイスを削除できませんでした。", + "title": "マーケットプレイスソース", + "description": "マーケットプレイスは固定されたGitリポジトリです。Orcaは既存のシステムGit資格情報を使用してプライベートリポジトリにアクセスします。", "urlLabel": "Git URL", - "urlDescription": "Use an HTTPS or SSH repository URL containing orca-marketplace.json.", + "urlDescription": "orca-marketplace.jsonを含むHTTPSまたはSSHリポジトリURLを使用してください。", "urlPlaceholder": "https://git.example.com/team/plugins.git", "refLabel": "Git ref", - "refDescription": "Choose a branch, tag, or commit. Every fetched index is recorded at an exact commit.", - "adding": "Adding…", - "add": "Add source", - "configured": "Configured sources", - "empty": "No marketplace sources configured.", - "official": "Official", - "owner": "Owner: {{value0}}", - "pinnedCommit": "Pinned at {{value0}}", - "stale": "Refresh failed. Browsing the last valid cached index.", - "refreshLabel": "Refresh {{value0}}", - "removeLabel": "Remove {{value0}}", - "done": "Done" + "refDescription": "ブランチ、タグ、またはコミットを選択。取得された各インデックスは正確なコミットに記録されます。", + "adding": "追加中…", + "add": "ソースを追加", + "configured": "設定済みソース", + "empty": "マーケットプレイスソースが設定されていません。", + "official": "公式", + "owner": "オーナー: {{value0}}", + "pinnedCommit": "{{value0}}に固定", + "stale": "更新に失敗。最後に有効なキャッシュされたインデックスを参照中。", + "refreshLabel": "{{value0}}を更新", + "removeLabel": "{{value0}}を削除", + "done": "完了" }, "PluginRemoveDialog": { - "title": "Remove plugin?", - "description": "This removes {{value0}} and its stored plugin data from this computer. You can install it again later.", - "cancel": "Cancel", - "remove": "Remove plugin" + "title": "プラグインを削除しますか?", + "description": "このコンピューターから{{value0}}とその保存されたプラグインデータを削除します。後で再インストールできます。", + "cancel": "キャンセル", + "remove": "プラグインを削除" }, "PluginRollbackDialog": { - "title": "Roll back plugin?", - "description": "This deactivates {{value0}} and restores its previous immutable version. If that version requests different access or instructional content, Orca will require another review.", - "cancel": "Cancel", - "confirm": "Roll back plugin" + "title": "プラグインをロールバックしますか?", + "description": "この{{value0}}を無効にし、以前の不変バージョンを復元します。そのバージョンが異なるアクセスまたはインストラクショナルコンテンツを要求した場合、Orcaは再レビューを要求します。", + "cancel": "キャンセル", + "confirm": "プラグインをロールバック" }, "PluginsSettingsSection": { - "systemLabel": "Plugin system", - "systemDescription": "Discovers installed plugins and lets you enable them individually. Nothing runs until you review and enable it. Workers always run on this computer; SSH workspace actions route through Orca.", - "featureOff": "Turn on the plugin system to see and manage installed plugins. Anything already installed stays on disk and stays disabled while the system is off.", - "loading": "Loading plugins…", - "noInstalledResultsTitle": "No matching plugins", - "noInstalledResults": "No installed plugins match this search.", - "emptyTitle": "No plugins installed yet", - "empty": "Browse the All tab to install plugins from a marketplace.", - "loadFailed": "Could not load plugins.", - "settingsUpdateFailed": "Could not save plugin settings.", - "install": "Install plugin", - "title": "Plugins", - "experimental": "Experimental", - "description": "Install and manage Orca plugins. Plugins run on this computer, even for SSH workspaces.", - "logsFailed": "Could not load plugin logs.", - "rollbackFailed": "Could not roll back this plugin. A previous immutable version may not be available." + "systemLabel": "プラグインシステム", + "systemDescription": "インストールされたプラグインを検出し、個別に有効にできます。レビューして有効にするまで何も実行されません。ワーカーは常にこのコンピューターで実行され、SSHワークスペースアクションはOrcaを通過します。", + "featureOff": "プラグインシステムをオンにして、インストールされたプラグインを管理。すでにインストールされているものはディスクに残り、システムがオフの間は無効のまま。", + "loading": "プラグインを読み込み中…", + "noInstalledResultsTitle": "一致するプラグインなし", + "noInstalledResults": "この検索に一致するインストールされたプラグインはありません。", + "emptyTitle": "まだインストールされたプラグインなし", + "empty": "すべてのタブを参照してマーケットプレイスからプラグインをインストール。", + "loadFailed": "プラグインをロードできませんでした。", + "settingsUpdateFailed": "プラグイン設定を保存できませんでした。", + "install": "プラグインをインストール", + "title": "プラグイン", + "experimental": "実験的", + "description": "Orcaプラグインをインストールおよび管理。SSHワークスペースでもこのコンピューターでプラグインが実行されます。", + "logsFailed": "プラグインログをロードできませんでした。", + "rollbackFailed": "このプラグインをロールバックできませんでした。以前の不変バージョンが利用できない可能性があります。" }, "PluginSettingsRow": { - "blocked": "Blocked", - "needsReview": "Needs review", - "restarting": "Restarting", - "invalid": "Invalid", - "error": "Error", - "disabled": "Disabled", - "running": "Running", - "enabled": "Enabled", - "loadingLogs": "Loading logs…", - "noLogs": "No log lines recorded.", - "logCount": "Last {{value0}} of up to 200 retained lines", - "reviewAndEnable": "Review & enable", - "official": "Official", - "dev": "Dev", - "bundled": "Bundled", - "noDescription": "No description provided.", - "killListMessage": "Orca's safety list disabled this plugin: {{value0}}", - "viewAdvisory": "View advisory", - "runtimeError": "The plugin stopped after an activation or worker error.", - "restartCount": " · {{value0}} restarts", - "moreActions": "More actions for {{value0}}", - "hideLogs": "Hide logs", - "viewLogs": "View logs", - "rollback": "Roll back", - "remove": "Remove", - "disableLabel": "Disable {{value0}}", - "enableLabel": "Enable {{value0}}", - "invalidPluginError": "The plugin manifest or installed files are invalid. Fix the plugin, then refresh." + "blocked": "ブロック済み", + "needsReview": "レビューが必要", + "restarting": "再起動中", + "invalid": "無効", + "error": "エラー", + "disabled": "無効", + "running": "実行中", + "enabled": "有効", + "loadingLogs": "ログを読み込み中…", + "noLogs": "記録されたログ行なし。", + "logCount": "最新{{value0}}行(最大200行保持)", + "reviewAndEnable": "レビューして有効にする", + "official": "公式", + "dev": "開発", + "bundled": "バンドル", + "noDescription": "説明が提供されていません。", + "killListMessage": "Orcaの安全リストがこのプラグインを無効にしました: {{value0}}", + "viewAdvisory": "アドバイザリーを表示", + "runtimeError": "プラグインが有効化またはワーカーエラー後に停止しました。", + "restartCount": " · {{value0}}回再起動", + "moreActions": "{{value0}}の追加アクション", + "hideLogs": "ログを非表示", + "viewLogs": "ログを表示", + "rollback": "ロールバック", + "remove": "削除", + "disableLabel": "{{value0}}を無効にする", + "enableLabel": "{{value0}}を有効にする", + "invalidPluginError": "プラグインマニフェストまたはインストールされたファイルが無効です。プラグインを修正してから更新してください。" }, "PluginVmRecipeConsentPreview": { - "create": "Create", - "suspend": "Suspend", - "resume": "Resume", - "destroy": "Destroy", - "heading": "VM recipe commands", - "commandLabel": "{{value0}} · {{value1}} command" + "create": "作成", + "suspend": "一時停止", + "resume": "再開", + "destroy": "破棄", + "heading": "VMレシピコマンド", + "commandLabel": "{{value0}} · {{value1}}個のコマンド" }, "pluginError": { - "installManifestMissing": "No readable orca-plugin.json was found. Choose the plugin's root folder.", - "installManifestInvalid": "orca-plugin.json is invalid. Ask the plugin author to fix the manifest.", - "incompatible": "This plugin requires a different Orca version.", - "installUnsafePath": "The plugin contains an unsafe file path or symlink and was not installed.", - "installLimit": "The plugin exceeds Orca's install size or file-count limits.", - "installGit": "Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.", - "invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.", - "invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.", - "invalidArtifact": "A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.", - "consentChanged": "The plugin changed while you were reviewing it. Close this dialog and review the updated permissions." + "installManifestMissing": "読み取り可能なorca-plugin.jsonが見つかりません。プラグインのルートフォルダーを選択してください。", + "installManifestInvalid": "orca-plugin.jsonが無効です。プラグイン作成者にマニフェストの修正を依頼してください。", + "incompatible": "このプラグインには異なるOrcaバージョンが必要です。", + "installUnsafePath": "プラグインに安全でないファイルパスまたはシンボリックリンクが含まれているためインストールされませんでした。", + "installLimit": "プラグインがOrcaのインストールサイズまたはファイル数制限を超過しています。", + "installGit": "Orcaが固定されたGitリビジョンを取得できませんでした。URL、#ref、アクセス、システムGit設定を確認してください。", + "invalidManifestMissing": "プラグインルートにorca-plugin.jsonがありません。追加してからプラグインを更新してください。", + "invalidManifest": "orca-plugin.jsonが無効です。修正してからプラグインを更新してください。", + "invalidArtifact": "宣言されたワーカーまたはパネルファイルが不足しているか安全でありません。プラグインファイルを修正してから更新してください。", + "consentChanged": "レビュー中にプラグインが変更されました。このダイアログを閉じて、更新された権限を確認してください。" }, "plugins": { "search": { - "title": "Plugins", - "description": "Install and manage experimental Orca plugins.", - "install": "install plugin", - "permissions": "plugin permissions", - "logs": "plugin logs", - "development": "development plugins" + "title": "プラグイン", + "description": "実験的なOrcaプラグインをインストールおよび管理。", + "install": "プラグインをインストール", + "permissions": "プラグイン権限", + "logs": "プラグインログ", + "development": "開発プラグイン" } } }, @@ -10421,18 +10429,18 @@ "a6de3e5a20": "キュー内のコメントをクリア", "49ea0937e4": "コメントを解決リストに追加", "9fecebb29d": "追加", - "b8c4e2a1f7": "View full logs", - "f8a2c91d04": "Queue for agent", - "b4e8a1c902": "Queued", + "b8c4e2a1f7": "完全なログを表示", + "f8a2c91d04": "エージェントのキューに追加", + "b4e8a1c902": "キュー待ち", "7c1f0a2b11": "開く", "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", "a7f0c7e8d1": "Queue", "8a621a2c4f": "グループ化", "b13f85d75c": "タイムライン", - "f5cf324efa": "Comment display options", + "f5cf324efa": "コメント表示オプション", "5e6e5a13fa": "表示", - "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", + "actionRequiredHint": "マージが解除される前に、GitHubで手動アクションが必要です(例:ワークフロー実行の承認)。", "b3195cba33": "作成者のボット指定を解除", "f588b46a6c": "作成者をボットとして指定" }, @@ -10828,17 +10836,17 @@ } }, "AiVaultPanel": { - "resumeCommandCopied": "Resume command copied", + "resumeCommandCopied": "再開コマンドをコピーしました", "valueCopied": "{{value0}} をコピーしました", "valueCopyFailed": "{{value0}} をコピーできません", "openWorkspaceBeforeResuming": "Open a workspace before resuming a session.", "localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。", "agentSessionQueued": "{{value0}} session queued", - "sessionHistory": "Agent Session History", - "shownRecent": "{{value0}} shown · {{value1}} recent", - "resumePastSessions": "Resume past sessions", - "refreshSessionHistory": "Refresh Session History", - "searchSessions": "Search sessions", + "sessionHistory": "エージェントセッション履歴", + "shownRecent": "{{value0}}表示 · {{value1}}最近", + "resumePastSessions": "過去のセッションを再開", + "refreshSessionHistory": "セッション履歴を更新", + "searchSessions": "セッションを検索", "clearSearch": "Clear search", "remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.", "transcriptsSkipped": "{{count}} transcript skipped", @@ -10875,8 +10883,8 @@ "group": "Group", "folder": "フォルダ", "agent": "エージェント", - "resetView": "Reset view", - "hideEmptySessions": "Hide empty sessions", + "resetView": "表示をリセット", + "hideEmptySessions": "空のセッションを非表示", "workspaceScope": "ワークスペース", "worktreeScope": "Worktree", "globalScope": "グローバル", @@ -11129,9 +11137,9 @@ "1eee1c7bd8": "PATH 上に agents が検出されませんでした。後でインストールするものを選択するか、空の terminal を使用して続行します。", "hideAgents": "agents を非表示にする", "showMoreAgents": "{{value0}} 件の agents をさらに表示→", - "yoloPermissionsLabel": "Yolo / Dangerously skip permissions", - "yoloPermissionsInfo": "Agent permission info", - "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" + "yoloPermissionsLabel": "Yolo / 危険: 権限チェックをスキップ", + "yoloPermissionsInfo": "エージェント権限情報", + "yoloPermissionsTooltip": "エージェントの権限チェックをスキップして割り込みを減らす" }, "FeatureSetupChecklist": { "77f74946f5": "Agents は相互にメッセージを送信し、タスクを実行し、引き継ぎを調整できます。", @@ -11381,16 +11389,25 @@ "searchGitLab": "GitLab MR と Issue を検索", "searchBranches": "ブランチを検索", "searchLinear": "Linear Issue を検索", - "workspaceName": "ワークスペース名" + "workspaceName": "ワークスペース名", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "No hosts are ready for this project.", "placeholder": "Choose host" }, "ProjectCombobox": { - "search": "Search projects...", "empty": "No projects match your search.", - "addProject": "Add a new project" + "addProject": "新しいプロジェクトを追加", + "label": "プロジェクト", + "browse": "プロジェクトを参照", + "listLabel": "プロジェクト", + "noProjects": "まだプロジェクトがありません。" + }, + "RunTargetCombobox": { + "listLabel": "実行ターゲット", + "label": "実行先", + "browse": "実行ターゲットを参照" } } }, @@ -11575,15 +11592,15 @@ "repair-failed": "Could not update the Windows Firewall rules", "public-title": "Windows marks this network as public", "missing-title": "Allow phone connections through Windows Firewall", - "public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.", - "missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.", - "open-settings": "Open network settings", - "waiting": "Waiting for Windows…", - "allow": "Allow phone connections", + "public-description": "Orcaモバイル接続を許可する前に、この信頼されたWi-Fiネットワークをプライベートに変更してください。", + "missing-description": "Windowsがペアリングサーバーをブロックする可能性があります。プライベートネットワーク上のこのOrcaアプリとTCPポート{{port}}のルールを追加してください。", + "open-settings": "ネットワーク設定を開く", + "waiting": "Windowsを待機中…", + "allow": "電話接続を許可", "repair-unverified": "Windows Firewall access could not be verified", - "blocked-title": "Windows may be blocking Orca Mobile", - "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", - "repair": "Repair firewall access" + "blocked-title": "WindowsがOrcaモバイルをブロックしている可能性があります", + "blocked-description": "既存のインバウンドブロックルールがペアリング例外を上書きする可能性があります。修復はこのOrcaアプリの競合するTCPルールを削除し、プライベートネットワーク上のポート{{port}}を許可します。", + "repair": "ファイアウォールアクセスを修復" } }, "gitlab": { @@ -11782,10 +11799,10 @@ "56a0271428": "隔離されたワークスペース", "ef737dcee1": "GitHub と Linear タスク", "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." + "47f16ecf34": "複数の作業を同時に進めましょう。各ワークスペースはブランチ、ターミナル、エージェントの活動をまとめます。", + "70aa182266": "目標を任せて離れます。コーディネーターエージェントが作業を分担し、並列でPRを出します。", + "f10c14dd9d": "タブ切り替えは不要。GitHubまたは Linear のバックログから選んで、ワンクリックでワークスペースを開始します。", + "5d6ee181b6": "ワークスペースを開いてターミナルに戻り、テスト、ログ、エージェント用にペインを分割します。" }, "FeatureWallBody": { "25ec5356d6": "設定" @@ -11975,8 +11992,25 @@ "agents": { "orchestration": { "OrchestrationPage": { - "30b509a467": "子供2人", - "862605d066": "2 つの子ワークスペース" + "30b509a467": "2 つの子ワークスペース", + "862605d066": "2 つの子ワークスペース", + "coordinatorName": "認証フローを再設計する", + "childPr1Name": "PR 1/2: users.sql を移行", + "childPr2Name": "PR 2/2: withSession ミドルウェア" + }, + "types": { + "coordinatorInitial": "認証の書き換えを 2 つの PR に分割中…", + "codexInitial": "users テーブルの移行を記述中…", + "claudeInitial": "withSession ミドルウェアをスケッチ中…", + "codexBeat1": "email_verified カラムを追加中…", + "claudeBeat1": "withSession ミドルウェアを接続中…", + "coordinatorBeat1": "PR 1/2 準備完了", + "coordinatorBeat2": "PR 2/2 準備完了" + }, + "steps": { + "name": "オーケストレーション", + "subtitle": "オーケストレーション", + "description": "エージェントが Orca ワークスペースを管理・調整し、より大きなタスクを実行できるようにします。" }, "StatusesPage": { "2f549fc0ba": "src/auth/session.test.ts", @@ -12264,6 +12298,7 @@ "4cc7b83ffe": "ファイルをフィルタリング...", "21783df79f": "ファイルツリーを折りたたむ", "481e63ca52": "ファイル", + "resizeFileTree": "ファイルツリーのサイズを変更", "d5ac717d65": "commit されていない", "39b6b9e4e4": "Committed on Branch" }, @@ -12274,7 +12309,7 @@ "84898c548d": "クリア", "88b70d0ef5": "コピー", "bb84b4c374": "AIメモ", - "948a5fd6c8": "クリアノート", + "948a5fd6c8": "ノートをクリア", "0f806a2ab1": "キャンセル", "80a286d8f5": "このワークツリーから?", "7e7ca60816": "変更されたファイル", @@ -12287,7 +12322,7 @@ "eb5f40e49c": "通常の双方向 diff パイプラインは競合に対して安全ではないため、この diff ビューでは未解決の競合が除外されます。", "45cf23b418": "メモを消去できませんでした。", "0fb870a0fe": "メモ", - "8ab3248fd8": "注記", + "8ab3248fd8": "メモ", "ec5053c7f5": "サイドバイサイド", "f786fd54e1": "インライン", "ea08dae15b": "すべて折りたたむ", @@ -12895,6 +12930,9 @@ "bb7f599ee7": "設定を開く", "2d5b9fabf9": "マイクへのアクセスが拒否されました。システム設定でアクセスを許可し、Orca を再起動。", "5d2c3e7ae3": "音声が検出されませんでした。" + }, + "DictationIndicator": { + "335e1bc6cb": "ディクテーションを停止する" } }, "dashboard": { @@ -13310,9 +13348,11 @@ "d90981f766": "時間", "6b914c5fbb": "日", "233b8c94b6": "ケイデンス", - "b08ccb4d06": "毎時", - "8cc026fd73": "毎週", - "c3e39e17cf": "カスタム" + "55b2ef82a4": "毎時", + "f0202f3a89": "毎日", + "57e83307d0": "平日", + "837d902bba": "毎週", + "ddba78647e": "カスタム cron" }, "AutomationSessionField": { "f3c76dce51": "再利用", @@ -13495,11 +13535,11 @@ } }, "AutomationProjectCombobox": { - "search": "Search projects/folders...", - "empty": "No projects/folders match your search.", - "chooseHost": "Choose automation host", - "adding": "Adding project…", - "addProject": "Add project" + "search": "プロジェクト/フォルダーを検索...", + "empty": "検索に一致するプロジェクト/フォルダーがありません。", + "chooseHost": "オートメーションホストを選択", + "adding": "プロジェクトを追加中…", + "addProject": "プロジェクトを追加" }, "AutomationSetupDecisionField": { "5a7863909c": "新しいワークスペースごとにセットアップを実行", @@ -13565,11 +13605,11 @@ "connect": { "dialog": { "63ce735809": "接続", - "4a2ab52781": "Verifying…", + "4a2ab52781": "確認中…", "79e7aaed39": "キャンセル", - "fdd26d81cc": "Atlassian account settings", - "8090504a3e": "Create a token in", - "7b3967c12f": "Atlassian API token", + "fdd26d81cc": "Atlassianアカウント設定", + "8090504a3e": "トークンを作成:", + "7b3967c12f": "Atlassian APIトークン", "3d81bf3ab3": "APIトークン", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian email", @@ -13581,11 +13621,11 @@ "b67e919bd5": "Jira instance type", "17787d6e4b": "Atlassian Cloud", "bc7a831773": "Self-hosted", - "3489e186d6": "Jira site URL", + "3489e186d6": "JiraサイトURL", "cbc27fa599": "https://jira.example.com", - "730d973bae": "Personal access token", - "8b9c7b9e7b": "Jira personal access token", - "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "730d973bae": "パーソナルアクセストークン", + "8b9c7b9e7b": "Jiraパーソナルアクセストークン", + "ccfb086d3e": "Jiraプロファイルのパーソナルアクセストークンでパーソナルアクセストークンを作成してください。", "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", "f49708c369": "Jira authentication method", "84a810dd0e": "Username & password", @@ -14218,12 +14258,12 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "Agent Dashboard settings", - "settingsTooltip": "Board settings" + "settings": "エージェント ダッシュボード設定", + "settingsTooltip": "ボード設定" }, "dashboard": { "sidebar": { - "label": "Agent Dashboard" + "label": "エージェント ダッシュボード" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 184d1acebef..cbad58d68b9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -333,6 +333,7 @@ "0708ed89f1": "Claude", "fc80296033": "Devin", "da41abbdd4": "Ante", + "060d152fb5": "Trae", "mimo_code_label": "MiMo Code" }, "skill": { @@ -678,6 +679,7 @@ "60428567b4": "원격 런타임이 활성화된 동안에는 로컬 terminal 표시를 사용할 수 없습니다.", "f8aaf2bde3": "워크스페이스가 업로드됨", "2fe88c2e06": "원격 워크스페이스 동기화를 사용할 수 없습니다.", + "workspaceChangedOnAnotherDevice": "다른 기기에서 워크스페이스가 변경되었습니다.", "2ec42e1c52": "아직 원격 워크스페이스가 없습니다.", "88214a785b": "워크스페이스 동기화가 로컬 세션 하이드레이션을 기다렸다가 시간 초과되었습니다.", "4f78ba5885": "워크스페이스가 동기화되었습니다.", @@ -743,7 +745,7 @@ "58a868e8e4": "오케스트레이션", "7c79d3b7bf": "선택 사항", "b1c2f8b0ac": "Claude, Codex, Gemini, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", - "f70ac54d38": "AI 제공업체 계정", + "f70ac54d38": "AI 제공자 계정", "4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.", "b49abbd2f7": "에이전트", "dev": "개발자 도구", @@ -767,6 +769,12 @@ }, "useLargeTextControlPaste": { "pasteTooLarge": "붙여넣기 내용이 너무 큽니다." + }, + "useMacosTccPromptNotice": { + "title": "반복되는 macOS 파일 접근 권한 요청 줄이기", + "description": "에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.", + "openSettings": "설정 열기", + "dismiss": "다시 표시하지 않기" } }, "components": { @@ -775,8 +783,8 @@ "9263e75f49": "Codex는 이전 계정을 사용하고 있습니다", "a4c8e1b2f7": "Codex가 아직 {{value0}} 계정으로 로그인되어 있습니다", "d3e8a1f4b2": "계정 전환됨", - "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account" + "9375620cc3": "{{value0}}을(를) 사용하려면 이 세션을 다시 시작하세요. 다시 시작하기 전까지는 이전 계정이 유지됩니다.", + "6133594b12": "이전 계정 유지" }, "FirstLaunchBanner": { "b9e1b966c7": "알림 닫기", @@ -1889,18 +1897,18 @@ "d5253b54af": "오류", "7ffc08506e": "확인", "522df222b9": "스피너", - "e944c2de43": "Update Verification Blocked", - "5b309b19f3": "Update Wasn't Installed", - "092f09fc14": "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version.", - "c9ff9b9ec2": "Check official releases", - "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", - "5194358929": "Hide details", - "8bc9e17d8f": "Show details", - "8cf17b10af": "Local Build Error", - "a4650b0dc4": "Could Not Use Local Build", - "b1e390250d": "Could not complete the local build switch.", - "d29740d175": "The selected build could not be used.", - "37d45c9ec1": "Choose Another Build" + "e944c2de43": "업데이트 확인이 차단됨", + "5b309b19f3": "업데이트가 설치되지 않음", + "092f09fc14": "설치 프로그램의 게시자가 Orca와 일치하지 않아 업데이트를 중지했습니다. 이 다운로드를 설치하지 마세요. 수정된 버전은 공식 릴리스를 확인하세요.", + "c9ff9b9ec2": "공식 릴리스 확인", + "a05992a26b": "서명 확인을 실행할 수 없었습니다. 일반적으로 바이러스 백신 소프트웨어가 차단했기 때문입니다. 다운로드를 다시 시도하거나 공식 릴리스에서 설치 프로그램을 받으세요.", + "5194358929": "세부 정보 숨기기", + "8bc9e17d8f": "세부 정보 표시", + "8cf17b10af": "로컬 빌드 오류", + "a4650b0dc4": "로컬 빌드를 사용할 수 없음", + "b1e390250d": "로컬 빌드 전환을 완료할 수 없었습니다.", + "d29740d175": "선택한 빌드를 사용할 수 없었습니다.", + "37d45c9ec1": "다른 빌드 선택" }, "WorktreeJumpPalette": { "ac037cfac2": "이동", @@ -2779,11 +2787,11 @@ "removeWorkspaceButton": "워크트리 제거" }, "TerminalRemoteRuntimeReconnectBanner": { - "retryingTitle": "Reconnecting to remote runtime", - "disconnectedTitle": "Remote runtime disconnected", - "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.", - "disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.", - "reconnectButton": "Reconnect" + "retryingTitle": "원격 런타임에 다시 연결 중", + "disconnectedTitle": "원격 런타임 연결 끊김", + "retryingBody": "Orca는 최대 1분간 재시도합니다. 연결이 복원되면 이 터미널이 재개됩니다.", + "disconnectedBody": "자동 재시도가 중지되었습니다. 이 터미널 세션을 재개하려면 다시 연결하세요.", + "reconnectButton": "다시 연결" } } }, @@ -3040,7 +3048,7 @@ }, "ResourceUsageStatusSegment": { "946d9f94d0": "취소", - "67c4ecda49": "이 terminal을 강제 종료합니다. 창에서 저장하지 않은 작업은 모두 손실됩니다. 이 작업은 취소할 수 없습니다.", + "67c4ecda49": "이 terminal을 강제 종료합니다. 패널에서 저장하지 않은 작업은 모두 손실됩니다. 이 작업은 취소할 수 없습니다.", "4bb076fa89": "강제 종료", "996295bff2": "고아 terminal", "92924a14e3": "비활성 워크스페이스 검토 ({{value0}})", @@ -3057,7 +3065,7 @@ "93b0de3c21": "다시 시작", "f85af9cda6": "리소스 스냅샷과 terminal 세션을 사용할 수 없습니다.", "f8e0d794b4": "데몬이 응답하지 않습니다", - "bd19fd7a59": "모든 세션 종료", + "bd19fd7a59": "모든 세션 강제 종료", "c9382662bb": "데몬 재시작", "59f178fe11": "{{value0}}, 데몬에 연결할 수 없습니다.", "21cacb16d1": "· 원격", @@ -3066,22 +3074,22 @@ "d659d71d2d": "워크스페이스 {{value0}} 재개", "bbcd9b7b85": "워크스페이스 축소", "c4a8968bdd": "워크스페이스 확장", - "b10695d6ce": "세션 종료", + "b10695d6ce": "세션 강제 종료", "288a4dd177": "Orca", "53dd5560ae": "Orca 접기", "e419d27083": "Orca 확장", - "41ae4fa725": "종료 중…", + "41ae4fa725": "강제 종료 중…", "138b99bd80": "이번 세션", "888dad8c55": "로드 중…", "6d9793d4bc": "리소스 관리자 - Terminals", "ca95d077db": "데몬에 연결할 수 없음", "a82253b458": "워크스페이스를 삭제합니다.", - "946724a70a": "기본 워크스페이스는 삭제할 수 없습니다.", - "16bc3c998a": "워크스페이스 삭제 {{value0}}", + "946724a70a": "메인 워크스페이스는 삭제할 수 없습니다.", + "16bc3c998a": "워크스페이스 {{value0}} 삭제", "0f9e50eb07": "기타", "d406915b78": "렌더러", - "81cd37af99": "기본", - "fa6d36758d": "세션 {{value0}} 종료", + "81cd37af99": "메인", + "fa6d36758d": "세션 {{value0}} 강제 종료", "b8f4a2c1d0e3": "고아 {{value0}}개", "c7e3b1a0d9f2": "고아 terminal {{value0}}개 종료", "d8f4c2b1e0a3": "고아 terminals {{value0}}개 종료", @@ -3135,7 +3143,7 @@ "54e8d6bb2d": "Fable", "d79c3362c4": "5h", "a79c64f87e": "Fable", - "8295903d17": "전환 후 이전 대화를 계속하기 전에 라이브 Claude terminals을 다시 시작하십시오.", + "8295903d17": "전환 후 이전 대화를 계속하려면 활성 Claude terminal을 다시 시작하세요.", "c98ea88392": "다른 계정 없음", "9332ba8684": "다음으로 전환", "d450654fa2": "Claude 계정", @@ -3143,7 +3151,7 @@ "3dd7ddfae1": "Claude 세부정보 및 계정 전환 도구 열기", "59c6e7b4e0": "보이는 세션은 지금 다시 시작됩니다. 나머지는 해당 작업 트리가 활성화될 때 다시 시작됩니다.", "c676918adc": "시스템 기본값", - "3325d996cb": "사용 한도 새로고침", + "3325d996cb": "rate-limit 새로고침", "fda8146810": "Kimi 사용 세부정보 열기", "629251f4b6": "OpenCode Go 사용 세부정보 열기", "d2375976eb": "Gemini 사용 세부정보 열기", @@ -3175,7 +3183,7 @@ "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", "caa0f39811": "지원:", - "97957ad3a3": "AI 공급자 계정을 연결하면 실시간으로 사용량을 확인하고 계정 간을 쉽게 전환할 수 있습니다.", + "97957ad3a3": "AI 제공자 계정을 연결하면 실시간으로 사용량을 확인하고 계정 간을 쉽게 전환할 수 있습니다.", "9a542f46c7": "상태 표시줄에서 숨기기", "84c3b15dca": "Agent 사용량 한도", "d663430cf9": "사용량 추적 설정" @@ -3350,35 +3358,35 @@ "gotIt": "Got it" }, "UsageRosterPanel": { - "title": "Usage", - "openDetails": "Open usage details", - "notSignedIn": "not signed in", - "allAgents": "all agents", - "usageDetails": "Usage details & history", - "loadingUsage": "Loading usage…", - "usageUnavailable": "Usage unavailable", - "noUsageData": "No usage data", - "detailed": "Detailed", - "compact": "Compact", - "footerDetailAria": "Usage footer detail", + "title": "사용량", + "openDetails": "사용량 세부 정보 열기", + "notSignedIn": "로그인하지 않음", + "allAgents": "모든 에이전트", + "usageDetails": "사용량 세부 정보 및 기록", + "loadingUsage": "사용량 로딩 중…", + "usageUnavailable": "사용량 사용 불가", + "noUsageData": "사용량 데이터 없음", + "detailed": "상세", + "compact": "압축", + "footerDetailAria": "사용량 푸터 세부 정보", "detailedTooltip": "막대, 레이블, 백분율을 포함한 전체 사용량", "compactTooltip": "압축된 사용: 가장 좁은 창만 사용" }, "RemoteServerUpdateStatusSegment": { - "updating": "Updating {{value0}}/{{value1}}", - "updatingTooltip": "Remote Orca Server updates are in progress", - "failed": "{{value0}} server updates failed", - "failedTooltip": "Open Remote Orca Server updates to review and retry", - "updated": "{{value0}} servers updated", - "updatedTooltip": "Remote Orca Server updates completed", - "failedOne": "1 server update failed", - "updatedOne": "1 server updated" + "updating": "{{value0}}/{{value1}} 업데이트 중", + "updatingTooltip": "원격 Orca 서버 업데이트 진행 중", + "failed": "{{value0}} 서버 업데이트 실패", + "failedTooltip": "원격 Orca 서버 업데이트를 열어 확인하고 다시 시도하세요", + "updated": "{{value0}} 서버 업데이트 완료", + "updatedTooltip": "원격 Orca 서버 업데이트 완료", + "failedOne": "1개 서버 업데이트 실패", + "updatedOne": "1개 서버 업데이트 완료" }, "resource": { "memory": { "metric": { - "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", - "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." + "workingSetDescription": "합산된 워킹 세트(WS). 공유 페이지가 둘 이상의 프로세스에 나타날 수 있습니다.", + "rssDescription": "합산된 레지던트 세트 크기(RSS). 공유 또는 별칭된 페이지가 둘 이상의 프로세스에 나타날 수 있습니다." } } }, @@ -5603,8 +5611,8 @@ "e7bb06007c": "LAN", "4a73f5217a": "다른 로컬 앱을 제어하는 ​​스크립트를 위한 Apple 이벤트.", "e119f0d66b": "자동화", - "7ca17b62c8": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다.", - "c566bca278": "전체 디스크 액세스", + "7ca17b62c8": "Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.", + "c566bca278": "전체 디스크 접근 권한", "9f35980756": "키 입력 주입, 창 제어 및 UI 자동화 도구입니다.", "5b2f22ca2d": "접근성", "0639db5496": "스크린샷, 시각적 자동화, UI 검사 도구.", @@ -5655,15 +5663,15 @@ "defaultViewNative": "Chat UI" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.", - "copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.", - "toggleLabel": "Toggle Agent Dashboard", - "modeLabel": "Open as", - "modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.", - "modeAriaLabel": "Agent Dashboard open mode", - "modeInWindow": "In-window", - "modePopout": "Pop-out" + "title": "에이전트 대시보드", + "description": "워크트리 전반에 걸친 에이전트를 모니터링하는 칸반 보드. 윈도우 내 또는 팝업으로 표시됩니다.", + "copy": "왼쪽 사이드바에 에이전트 대시보드 항목을 추가합니다. 열면 주의 중, 작업 중, 유휴 상태의 에이전트를 모니터링하고 라이브 터미널로 이동할 수 있습니다.", + "toggleLabel": "에이전트 대시보드 전환", + "modeLabel": "열기 형식", + "modeCopy": "대시보드를 사이드바 옆 윈도우 내 보드 또는 별도의 팝업 윈도우로 표시합니다.", + "modeAriaLabel": "에이전트 대시보드 열기 모드", + "modeInWindow": "윈도우 내", + "modePopout": "팝업" } }, "FloatingWorkspacePane": { @@ -7498,7 +7506,7 @@ "9c4d5f0894": "관리자", "c690a15849": "리소스", "81ef5abc2f": "상태 표시줄에 CPU, 메모리, terminal 세션 및 워크스페이스 디스크 사용량을 표시합니다.", - "7cf005b29f": "자원 관리자", + "7cf005b29f": "리소스 관리자", "fe192b060e": "호스트", "f4997e0f8a": "연결", "a278406ed5": "원격", @@ -7858,8 +7866,8 @@ "grok": "grok" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out." + "title": "에이전트 대시보드", + "description": "워크트리 전반에 걸친 에이전트를 모니터링하는 칸반 보드. 윈도우 내 또는 팝업으로 표시됩니다." } } }, @@ -9502,283 +9510,283 @@ "title": "Update Remote Orca Servers", "description": "Review paired servers and update supported installs from this Orca client.", "restartWarning": "Updating restarts these servers. {{value0}} live tabs and {{value1}} terminal panes may briefly disconnect.", - "checking": "Checking paired servers…", - "empty": "No paired Remote Orca Servers.", - "checkAgain": "Check again", - "updating": "Updating servers…", - "updateAll": "Update {{value0}} servers", - "noUpdates": "No updates available", - "updateOne": "Update server", - "downloadProgress": "{{value0}} download progress", - "liveTabOne": "1 live tab", - "liveTabs": "{{value0}} live tabs", - "livePaneOne": "1 live pane", - "livePanes": "{{value0}} live panes" + "checking": "페어링된 서버 확인 중…", + "empty": "페어링된 원격 Orca 서버가 없습니다.", + "checkAgain": "다시 확인", + "updating": "서버 업데이트 중…", + "updateAll": "{{value0}}개 서버 업데이트", + "noUpdates": "사용 가능한 업데이트 없음", + "updateOne": "서버 업데이트", + "downloadProgress": "{{value0}} 다운로드 진행률", + "liveTabOne": "1개 활성 탭", + "liveTabs": "{{value0}}개 활성 탭", + "livePaneOne": "1개 활성 패널", + "livePanes": "{{value0}}개 활성 패널" }, "RemoteServerUpdateStatus": { - "checking": "Checking…", - "available": "Update available", - "current": "Up to date", - "manual": "Manual update", - "offline": "Offline", - "queued": "Queued", - "checkingUpdate": "Checking update…", - "downloading": "Downloading…", - "restarting": "Restarting…", - "updated": "Updated", - "failed": "Update failed", - "serviceManagerHelp": "Update Orca through the service manager that starts this server.", - "unpackedHelp": "Development builds must be updated from their source checkout.", - "legacyHelp": "Update this server manually once to enable remote updates." + "checking": "확인 중…", + "available": "업데이트 사용 가능", + "current": "최신 상태", + "manual": "수동 업데이트", + "offline": "오프라인", + "queued": "대기 중", + "checkingUpdate": "업데이트 확인 중…", + "downloading": "다운로드 중…", + "restarting": "재시작 중…", + "updated": "업데이트됨", + "failed": "업데이트 실패", + "serviceManagerHelp": "이 서버를 시작하는 서비스 관리자를 통해 Orca를 업데이트하세요.", + "unpackedHelp": "개발 빌드는 소스 체크아웃에서 업데이트해야 합니다.", + "legacyHelp": "원격 업데이트를 활성화하려면 이 서버를 수동으로 한 번 업데이트하세요." }, "PluginConsentDialog": { - "workerTrust": "Background worker — runs its own process", - "instructionalTrust": "Instructional content — runs later under user or agent authority", - "declarativeTrust": "Declarative content — no plugin code", - "panelTrust": "Panel or host-integrated content — no worker process", - "trustShortWorker": "Worker", - "trustShortInstructional": "Instructional", - "trustShortPanel": "Panel", - "trustShortDeclarative": "Declarative", - "reviewTitle": "Review plugin", - "title": "Review permissions", - "mixedTitle": "Review access and content", - "instructionalTitle": "Review plugin content", + "workerTrust": "백그라운드 워커 — 자체 프로세스 실행", + "instructionalTrust": "지시 내용 — 사용자 또는 에이전트 권한으로 나중에 실행", + "declarativeTrust": "선언적 내용 — 플러그인 코드 없음", + "panelTrust": "패널 또는 호스트 통합 내용 — 워커 프로세스 없음", + "trustShortWorker": "워커", + "trustShortInstructional": "지시", + "trustShortPanel": "패널", + "trustShortDeclarative": "선언적", + "reviewTitle": "플러그인 검토", + "title": "권한 검토", + "mixedTitle": "접근 및 내용 검토", + "instructionalTitle": "플러그인 내용 검토", "subtitle": "{{value0}} v{{value1}} · {{value2}}", - "reconsent": "Permissions, the worker trust tier, or instructional content changed since you last reviewed this plugin. Review it again before it can run.", - "capabilities": "This plugin can", - "warning": "These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes.", - "instructionalWarning": "This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.", - "panelWarning": "These permissions limit how the plugin uses Orca's API. This plugin has no background worker.", - "declarativeWarning": "This plugin contributes validated content only. It does not run a background worker or receive access to Orca's API.", - "keepDisabled": "Keep Disabled", - "enable": "Enable plugin", + "reconsent": "마지막으로 이 플러그인을 검토한 이후로 권한, 워커 신뢰 등급 또는 지시 내용이 변경되었습니다. 실행하기 전에 다시 검토하세요.", + "capabilities": "이 플러그인은 다음을 수행할 수 있습니다", + "warning": "이러한 권한은 플러그인이 Orca API를 사용하는 방식을 제한합니다. 워커는 여전히 컴퓨터에서 일반 프로세스로 실행되며 파일, 네트워크 및 기타 프로세스에 대한 전체 액세스 권한을 보유합니다.", + "instructionalWarning": "이 플러그인에는 워커 프로세스가 없습니다. 지시 내용은 사용자나 에이전트가 사용할 때 여전히 작업을 발생시킬 수 있습니다. 활성화하기 전에 아래 지침과 명령을 검토하세요.", + "panelWarning": "이러한 권한은 플러그인이 Orca API를 사용하는 방식을 제한합니다. 이 플러그인에는 백그라운드 워커가 없습니다.", + "declarativeWarning": "이 플러그인은 검증된 내용만 기여합니다. 백그라운드 워커를 실행하지 않으며 Orca API에 대한 액세스를 받지 않습니다.", + "keepDisabled": "비활성화 유지", + "enable": "플러그인 활성화", "capability": { - "workspaceRead": "Read the name, branch, and terminal list of your focused worktree", - "terminalSend": "Type text into a terminal you can see (always a specific terminal)", - "notificationsShow": "Show desktop notifications labeled with the plugin name", - "storage": "Store data in the plugin's own storage folder", - "secrets": "Store and read secrets in the plugin's own encrypted vault", - "eventsSubscribe": "Get notified when worktrees are created or removed and when agent status changes", - "settingsOwn": "Read and change the plugin's own settings" + "workspaceRead": "포커스된 워크트리의 이름, 브랜치 및 터미널 목록 읽기", + "terminalSend": "보이는 터미널에 텍스트 입력 (항상 특정 터미널)", + "notificationsShow": "플러그인 이름이 표시된 데스크톱 알림 표시", + "storage": "플러그인 자체 저장소 폴더에 데이터 저장", + "secrets": "플러그인 자체 암호화 금고에 비밀 저장 및 읽기", + "eventsSubscribe": "워크트리가 생성되거나 제거되고 에이전트 상태가 변경될 때 알림 받기", + "settingsOwn": "플러그인 자체 설정 읽기 및 변경" }, - "decisionFailed": "Could not save the permission decision. Try again." + "decisionFailed": "권한 결정을 저장할 수 없습니다. 다시 시도하세요." }, "PluginConsentProvenance": { - "official": "Official", - "bundled": "Bundled with Orca", - "local": "Local folder", - "community": "Community", - "source": "Source", - "sourceLabel": "Source", - "commit": "Pinned commit", - "localCommit": "Local folder — no commit", - "indexCommit": "Index commit" + "official": "공식", + "bundled": "Orca와 번들", + "local": "로컬 폴더", + "community": "커뮤니티", + "source": "소스", + "sourceLabel": "소스", + "commit": "고정된 커밋", + "localCommit": "로컬 폴더 — 커밋 없음", + "indexCommit": "인덱스 커밋" }, "PluginDevelopmentSection": { - "saveFailed": "Could not save development plugin paths.", - "pathRequired": "Enter a plugin folder path.", - "title": "Development", - "help": "Load plugins directly from folders on this computer while you develop them. Dev plugins still require permission review. Workers run on this desktop host; SSH workspace actions route through Orca, so paths here are desktop paths.", - "remove": "Remove", - "pathLabel": "Development plugin folder path", + "saveFailed": "개발 플러그인 경로를 저장할 수 없습니다.", + "pathRequired": "플러그인 폴더 경로를 입력하세요.", + "title": "개발", + "help": "컴퓨터의 폴더에서 직접 플러그인을 로드하여 개발하세요. 개발 플러그인에는 여전히 권한 검토가 필요합니다. 워커는 이 데스크톱 호스트에서 실행되며 SSH 워크스페이스 작업은 Orca를 통해 라우팅됩니다. 따라서 여기의 경로는 데스크톱 경로입니다.", + "remove": "제거", + "pathLabel": "개발 플러그인 폴더 경로", "placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "add": "Add path" + "add": "경로 추가" }, "PluginInstallDialog": { - "localRequired": "Enter the plugin folder path.", - "gitUrlRequired": "Enter a repository URL.", - "gitUrlInvalid": "Use an HTTPS or SSH Git URL. Executable Git helper protocols are not allowed.", - "gitRefRequired": "Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.", - "title": "Install plugin", - "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", - "source": "Install source", - "localTab": "Local folder", + "localRequired": "플러그인 폴더 경로를 입력하세요.", + "gitUrlRequired": "저장소 URL을 입력하세요.", + "gitUrlInvalid": "HTTPS 또는 SSH Git URL을 사용하세요. 실행 가능한 Git 헬퍼 프로토콜은 허용되지 않습니다.", + "gitRefRequired": "설치를 고정하려면 명시적인 #ref(태그 또는 커밋)를 추가하세요. 예: #v0.1.0.", + "title": "플러그인 설치", + "description": "설치는 플러그인을 Orca에 복사하고 검토를 위한 권한을 표시합니다. 활성화할 때까지 플러그인 코드가 실행되지 않습니다.", + "source": "설치 소스", + "localTab": "로컬 폴더", "gitTab": "Git URL", - "localLabel": "Plugin folder path", + "localLabel": "플러그인 폴더 경로", "localPlaceholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", - "localHelp": "Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.", - "gitLabel": "Repository URL with #ref", + "localHelp": "이 컴퓨터에서 orca-plugin.json이 있는 폴더의 전체 경로. 경로는 입력된 대로 정확히 사용됩니다.", + "gitLabel": "#ref가 있는 저장소 URL", "gitPlaceholder": "https://git.example/acme/orca-notes#v0.1.0", - "gitHelp": "Append an explicit #ref — a tag or commit — so the install is pinned. Works with GitHub, GitLab, and any git host.", - "cancel": "Cancel", - "installing": "Installing…", - "install": "Install", - "installFailed": "Plugin installation failed. Check the source and try again." + "gitHelp": "설치를 고정하려면 명시적인 #ref — 태그 또는 커밋 —를 추가하세요. GitHub, GitLab 및 모든 git 호스트에서 작동합니다.", + "cancel": "취소", + "installing": "설치 중…", + "install": "설치", + "installFailed": "플러그인 설치에 실패했습니다. 소스를 확인하고 다시 시도하세요." }, "PluginKeybindingConsentPreview": { - "heading": "Keyboard shortcuts", - "worktree": "Runs only while a workspace is active.", - "global": "Runs in the app without requiring an active workspace.", - "shadows": "Replaces: {{value0}}" + "heading": "키보드 단축키", + "worktree": "워크스페이스가 활성화된 동안만 실행됩니다.", + "global": "활성 워크스페이스 없이 앱에서 실행됩니다.", + "shadows": "대체: {{value0}}" }, "PluginMarketplaceBrowser": { - "loadFailed": "Could not load marketplace plugins.", - "refreshFailed": "Could not refresh marketplaces. Cached listings remain available.", - "previewFailed": "Could not prepare this plugin for review. Refresh the marketplace and try again.", - "installFailed": "Could not install this plugin. The reviewed source may have changed.", - "manageSources": "Manage sources", - "refreshing": "Refreshing…", - "refresh": "Refresh", - "noInstalledTitle": "No plugins installed", - "noInstalled": "Plugins you install appear here.", - "loading": "Loading marketplace plugins…", - "tryAgain": "Try again", - "noSourcesTitle": "No marketplaces configured", - "noSources": "Add an official, community, or private Git marketplace to browse plugins.", - "addSource": "Add marketplace", - "noResultsTitle": "No matching plugins", - "noResults": "No marketplace plugins match this search.", - "clearSearch": "Clear search", - "emptyTitle": "Nothing listed yet", - "empty": "The configured marketplaces do not list any plugins." + "loadFailed": "마켓플레이스 플러그인을 로드할 수 없습니다.", + "refreshFailed": "마켓플레이스를 새로고침할 수 없습니다. 캐시된 목록은 여전히 사용 가능합니다.", + "previewFailed": "이 플러그인을 검토용으로 준비할 수 없습니다. 마켓플레이스를 새로고침하고 다시 시도하세요.", + "installFailed": "이 플러그인을 설치할 수 없습니다. 검토된 소스가 변경되었을 수 있습니다.", + "manageSources": "소스 관리", + "refreshing": "새로고침 중…", + "refresh": "새로고침", + "noInstalledTitle": "설치된 플러그인 없음", + "noInstalled": "설치한 플러그인이 여기에 표시됩니다.", + "loading": "마켓플레이스 플러그인 로딩 중…", + "tryAgain": "다시 시도", + "noSourcesTitle": "마켓플레이스 미구성", + "noSources": "플러그인을 탐색하려면 공식, 커뮤니티 또는 비공개 Git 마켓플레이스를 추가하세요.", + "addSource": "마켓플레이스 추가", + "noResultsTitle": "일치하는 플러그인 없음", + "noResults": "이 검색에 일치하는 마켓플레이스 플러그인이 없습니다.", + "clearSearch": "검색 지우기", + "emptyTitle": "아무것도 없습니다", + "empty": "구성된 마켓플레이스에 플러그인이 나열되어 있지 않습니다." }, "PluginMarketplaceListingRow": { - "official": "Official", - "installed": "Installed", - "noDescription": "No description provided.", - "blocked": "Blocked by Orca's safety list: {{value0}}", - "blockedAction": "Blocked", - "checkUpdate": "Check for update", - "install": "Install" + "official": "공식", + "installed": "설치됨", + "noDescription": "설명이 제공되지 않았습니다.", + "blocked": "Orca 안전 목록에 의해 차단됨: {{value0}}", + "blockedAction": "차단됨", + "checkUpdate": "업데이트 확인", + "install": "설치" }, "PluginMarketplacePreviewDialog": { - "languagePacksOne": "1 language pack", - "languagePacks": "{{value0}} language packs", - "commandsOne": "1 command", - "commands": "{{value0}} commands", - "keybindingsOne": "1 keyboard shortcut", - "keybindings": "{{value0}} keyboard shortcuts", - "vmRecipesOne": "1 VM recipe", - "vmRecipes": "{{value0}} VM recipes", - "panelsOne": "1 panel", - "panels": "{{value0}} panels", - "eventsOne": "1 event subscription", - "events": "{{value0}} event subscriptions", - "worker": "Background worker", + "languagePacksOne": "1개 언어 팩", + "languagePacks": "{{value0}}개 언어 팩", + "commandsOne": "1개 명령", + "commands": "{{value0}}개 명령", + "keybindingsOne": "1개 키보드 단축키", + "keybindings": "{{value0}}개 키보드 단축키", + "vmRecipesOne": "1개 VM 레시피", + "vmRecipes": "{{value0}}개 VM 레시피", + "panelsOne": "1개 패널", + "panels": "{{value0}}개 패널", + "eventsOne": "1개 이벤트 구독", + "events": "{{value0}}개 이벤트 구독", + "worker": "백그라운드 워커", "versionLine": "v{{value0}} · {{value1}}", - "includes": "Includes", - "noContributions": "Manifest metadata only", - "capabilities": "Requested access", - "workerWarning": "Capabilities limit how this plugin uses Orca's API. Its worker still runs as a normal process on this computer with full access to your files, network, and other processes.", - "blocked": "Orca's safety list blocks this plugin: {{value0}}", - "current": "This exact plugin content is already installed.", - "close": "Close", - "cancel": "Cancel", - "update": "Update plugin", - "install": "Install plugin" + "includes": "포함", + "noContributions": "매니페스트 메타데이터만", + "capabilities": "요청된 액세스", + "workerWarning": "기능은 이 플러그인이 Orca API를 사용하는 방식을 제한합니다. 워커는 여전히 컴퓨터에서 일반 프로세스로 실행되며 파일, 네트워크 및 기타 프로세스에 대한 전체 액세스 권한을 보유합니다.", + "blocked": "Orca 안전 목록이 이 플러그인을 차단했습니다: {{value0}}", + "current": "이 플러그인 내용이 이미 설치되어 있습니다.", + "close": "닫기", + "cancel": "취소", + "update": "플러그인 업데이트", + "install": "플러그인 설치" }, "PluginMarketplaceSourceDialog": { - "addFailed": "Could not add this marketplace. Check the Git URL, ref, and your Git credentials.", - "refreshFailed": "Could not refresh this marketplace. Its last valid cached index is still available.", - "removeFailed": "Could not remove this marketplace.", - "title": "Marketplace sources", - "description": "Marketplaces are pinned Git repositories. Orca uses your existing system Git credentials for private repositories.", + "addFailed": "이 마켓플레이스를 추가할 수 없습니다. Git URL, ref 및 Git 자격 증명을 확인하세요.", + "refreshFailed": "이 마켓플레이스를 새로고침할 수 없습니다. 마지막으로 유효한 캐시된 인덱스는 여전히 사용 가능합니다.", + "removeFailed": "이 마켓플레이스를 제거할 수 없습니다.", + "title": "마켓플레이스 소스", + "description": "마켓플레이스는 고정된 Git 저장소입니다. Orca는 기존 시스템 Git 자격 증명을 사용하여 비공개 저장소에 액세스합니다.", "urlLabel": "Git URL", - "urlDescription": "Use an HTTPS or SSH repository URL containing orca-marketplace.json.", + "urlDescription": "orca-marketplace.json이 포함된 HTTPS 또는 SSH 저장소 URL을 사용하세요.", "urlPlaceholder": "https://git.example.com/team/plugins.git", "refLabel": "Git ref", - "refDescription": "Choose a branch, tag, or commit. Every fetched index is recorded at an exact commit.", - "adding": "Adding…", - "add": "Add source", - "configured": "Configured sources", - "empty": "No marketplace sources configured.", - "official": "Official", - "owner": "Owner: {{value0}}", - "pinnedCommit": "Pinned at {{value0}}", - "stale": "Refresh failed. Browsing the last valid cached index.", - "refreshLabel": "Refresh {{value0}}", - "removeLabel": "Remove {{value0}}", - "done": "Done" + "refDescription": "브랜치, 태그 또는 커밋을 선택하세요. 가져온 각 인덱스는 정확한 커밋에 기록됩니다.", + "adding": "추가 중…", + "add": "소스 추가", + "configured": "구성된 소스", + "empty": "구성된 마켓플레이스 소스가 없습니다.", + "official": "공식", + "owner": "소유자: {{value0}}", + "pinnedCommit": "{{value0}}에 고정됨", + "stale": "새로고침 실패. 마지막으로 유효한 캐시된 인덱스를 탐색하고 있습니다.", + "refreshLabel": "{{value0}} 새로고침", + "removeLabel": "{{value0}} 제거", + "done": "완료" }, "PluginRemoveDialog": { - "title": "Remove plugin?", - "description": "This removes {{value0}} and its stored plugin data from this computer. You can install it again later.", - "cancel": "Cancel", - "remove": "Remove plugin" + "title": "플러그인을 제거하시겠습니까?", + "description": "이 작업은 이 컴퓨터에서 {{value0}} 및 저장된 플러그인 데이터를 제거합니다. 나중에 다시 설치할 수 있습니다.", + "cancel": "취소", + "remove": "플러그인 제거" }, "PluginRollbackDialog": { - "title": "Roll back plugin?", - "description": "This deactivates {{value0}} and restores its previous immutable version. If that version requests different access or instructional content, Orca will require another review.", - "cancel": "Cancel", - "confirm": "Roll back plugin" + "title": "플러그인을 롤백하시겠습니까?", + "description": "이 작업은 {{value0}}을(를) 비활성화하고 이전 불변 버전을 복원합니다. 해당 버전이 다른 액세스 또는 지시 내용을 요청하면 Orca에서 다시 검토가 필요합니다.", + "cancel": "취소", + "confirm": "플러그인 롤백" }, "PluginsSettingsSection": { - "systemLabel": "Plugin system", - "systemDescription": "Discovers installed plugins and lets you enable them individually. Nothing runs until you review and enable it. Workers always run on this computer; SSH workspace actions route through Orca.", - "featureOff": "Turn on the plugin system to see and manage installed plugins. Anything already installed stays on disk and stays disabled while the system is off.", - "loading": "Loading plugins…", - "noInstalledResultsTitle": "No matching plugins", - "noInstalledResults": "No installed plugins match this search.", - "emptyTitle": "No plugins installed yet", - "empty": "Browse the All tab to install plugins from a marketplace.", - "loadFailed": "Could not load plugins.", - "settingsUpdateFailed": "Could not save plugin settings.", - "install": "Install plugin", - "title": "Plugins", - "experimental": "Experimental", - "description": "Install and manage Orca plugins. Plugins run on this computer, even for SSH workspaces.", - "logsFailed": "Could not load plugin logs.", - "rollbackFailed": "Could not roll back this plugin. A previous immutable version may not be available." + "systemLabel": "플러그인 시스템", + "systemDescription": "설치된 플러그인을 발견하고 개별적으로 활성화할 수 있게 합니다. 검토하고 활성화할 때까지 아무것도 실행되지 않습니다. 워커는 항상 이 컴퓨터에서 실행되며 SSH 워크스페이스 작업은 Orca를 통해 라우팅됩니다.", + "featureOff": "플러그인 시스템을 켜서 설치된 플러그인을 관리하세요. 이미 설치된 것은 디스크에 유지되며 시스템이 꺼져 있을 때 비활성화 상태를 유지합니다.", + "loading": "플러그인 로딩 중…", + "noInstalledResultsTitle": "일치하는 플러그인 없음", + "noInstalledResults": "이 검색에 일치하는 설치된 플러그인이 없습니다.", + "emptyTitle": "아직 설치된 플러그인이 없습니다", + "empty": "모든 탭을 둘러보며 마켓플레이스에서 플러그인을 설치하세요.", + "loadFailed": "플러그인을 로드할 수 없습니다.", + "settingsUpdateFailed": "플러그인 설정을 저장할 수 없습니다.", + "install": "플러그인 설치", + "title": "플러그인", + "experimental": "실험적", + "description": "Orca 플러그인을 설치하고 관리하세요. SSH 워크스페이스의 경우에도 플러그인은 이 컴퓨터에서 실행됩니다.", + "logsFailed": "플러그인 로그를 로드할 수 없습니다.", + "rollbackFailed": "이 플러그인을 롤백할 수 없습니다. 이전 불변 버전이 사용 가능하지 않을 수 있습니다." }, "PluginSettingsRow": { - "blocked": "Blocked", - "needsReview": "Needs review", - "restarting": "Restarting", - "invalid": "Invalid", - "error": "Error", - "disabled": "Disabled", - "running": "Running", - "enabled": "Enabled", - "loadingLogs": "Loading logs…", - "noLogs": "No log lines recorded.", - "logCount": "Last {{value0}} of up to 200 retained lines", - "reviewAndEnable": "Review & enable", - "official": "Official", - "dev": "Dev", - "bundled": "Bundled", - "noDescription": "No description provided.", - "killListMessage": "Orca's safety list disabled this plugin: {{value0}}", - "viewAdvisory": "View advisory", - "runtimeError": "The plugin stopped after an activation or worker error.", - "restartCount": " · {{value0}} restarts", - "moreActions": "More actions for {{value0}}", - "hideLogs": "Hide logs", - "viewLogs": "View logs", - "rollback": "Roll back", - "remove": "Remove", - "disableLabel": "Disable {{value0}}", - "enableLabel": "Enable {{value0}}", - "invalidPluginError": "The plugin manifest or installed files are invalid. Fix the plugin, then refresh." + "blocked": "차단됨", + "needsReview": "검토 필요", + "restarting": "재시작 중", + "invalid": "유효하지 않음", + "error": "오류", + "disabled": "비활성화됨", + "running": "실행 중", + "enabled": "활성화됨", + "loadingLogs": "로그 로딩 중…", + "noLogs": "기록된 로그 라인이 없습니다.", + "logCount": "최근 {{value0}}줄 (최대 200줄 보존)", + "reviewAndEnable": "검토 및 활성화", + "official": "공식", + "dev": "개발", + "bundled": "번들", + "noDescription": "설명이 제공되지 않았습니다.", + "killListMessage": "Orca 안전 목록이 이 플러그인을 비활성화했습니다: {{value0}}", + "viewAdvisory": "공지 보기", + "runtimeError": "플러그인이 활성화 또는 워커 오류 후에 중지되었습니다.", + "restartCount": " · {{value0}}회 재시작", + "moreActions": "{{value0}}의 더 많은 작업", + "hideLogs": "로그 숨기기", + "viewLogs": "로그 보기", + "rollback": "롤백", + "remove": "제거", + "disableLabel": "{{value0}} 비활성화", + "enableLabel": "{{value0}} 활성화", + "invalidPluginError": "플러그인 매니페스트 또는 설치된 파일이 유효하지 않습니다. 플러그인을 수정한 후 새로고침하세요." }, "PluginVmRecipeConsentPreview": { - "create": "Create", - "suspend": "Suspend", - "resume": "Resume", - "destroy": "Destroy", - "heading": "VM recipe commands", - "commandLabel": "{{value0}} · {{value1}} command" + "create": "생성", + "suspend": "일시 중지", + "resume": "재개", + "destroy": "파괴", + "heading": "VM 레시피 명령", + "commandLabel": "{{value0}} · {{value1}}개 명령" }, "pluginError": { - "installManifestMissing": "No readable orca-plugin.json was found. Choose the plugin's root folder.", - "installManifestInvalid": "orca-plugin.json is invalid. Ask the plugin author to fix the manifest.", - "incompatible": "This plugin requires a different Orca version.", - "installUnsafePath": "The plugin contains an unsafe file path or symlink and was not installed.", - "installLimit": "The plugin exceeds Orca's install size or file-count limits.", - "installGit": "Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.", - "invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.", - "invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.", - "invalidArtifact": "A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.", - "consentChanged": "The plugin changed while you were reviewing it. Close this dialog and review the updated permissions." + "installManifestMissing": "읽을 수 있는 orca-plugin.json이 없습니다. 플러그인 루트 폴더를 선택하세요.", + "installManifestInvalid": "orca-plugin.json이 유효하지 않습니다. 플러그인 작성자에게 매니페스트 수정을 요청하세요.", + "incompatible": "이 플러그인은 다른 Orca 버전이 필요합니다.", + "installUnsafePath": "플러그인에 안전하지 않은 파일 경로 또는 심볼릭 링크가 있어 설치되지 않았습니다.", + "installLimit": "플러그인의 설치 크기 또는 파일 수 제한을 초과합니다.", + "installGit": "Orca가 고정된 Git 리비전을 가져올 수 없습니다. URL, #ref, 액세스 및 시스템 Git 설정을 확인하세요.", + "invalidManifestMissing": "플러그인 루트에 orca-plugin.json이 없습니다. 추가한 후 플러그인을 새로고침하세요.", + "invalidManifest": "orca-plugin.json이 유효하지 않습니다. 수정한 후 플러그인을 새로고침하세요.", + "invalidArtifact": "선언된 워커 또는 패널 파일이 누락되었거나 안전하지 않습니다. 플러그인 파일을 수정한 후 새로고침하세요.", + "consentChanged": "검토하는 동안 플러그인이 변경되었습니다. 이 대화 상자를 닫고 업데이트된 권한을 검토하세요." }, "plugins": { "search": { - "title": "Plugins", - "description": "Install and manage experimental Orca plugins.", - "install": "install plugin", - "permissions": "plugin permissions", - "logs": "plugin logs", - "development": "development plugins" + "title": "플러그인", + "description": "실험적 Orca 플러그인을 설치하고 관리하세요.", + "install": "플러그인 설치", + "permissions": "플러그인 권한", + "logs": "플러그인 로그", + "development": "개발 플러그인" } } }, @@ -11381,16 +11389,25 @@ "searchGitLab": "GitLab MR 및 이슈 검색", "searchBranches": "브랜치 검색", "searchLinear": "Linear 이슈 검색", - "workspaceName": "워크스페이스 이름" + "workspaceName": "워크스페이스 이름", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "이 프로젝트에 준비된 호스트가 없습니다.", "placeholder": "호스트 선택" }, "ProjectCombobox": { - "search": "프로젝트 검색...", "empty": "검색과 일치하는 프로젝트가 없습니다.", - "addProject": "Add a new project" + "addProject": "새 프로젝트 추가", + "label": "프로젝트", + "browse": "프로젝트 검색", + "listLabel": "프로젝트", + "noProjects": "아직 프로젝트가 없습니다." + }, + "RunTargetCombobox": { + "listLabel": "실행 대상", + "label": "실행 위치", + "browse": "실행 대상 검색" } } }, @@ -11976,7 +11993,24 @@ "orchestration": { "OrchestrationPage": { "30b509a467": "하위 2개", - "862605d066": "2개의 하위 워크스페이스" + "862605d066": "2개의 하위 워크스페이스", + "coordinatorName": "인증 흐름 재설계", + "childPr1Name": "PR 1/2: users.sql 마이그레이션", + "childPr2Name": "PR 2/2: withSession 미들웨어" + }, + "types": { + "coordinatorInitial": "인증 재작성을 2개의 PR로 분할 중…", + "codexInitial": "users 테이블 마이그레이션 작성 중…", + "claudeInitial": "withSession 미들웨어 스케치 중…", + "codexBeat1": "email_verified 컬럼 추가 중…", + "claudeBeat1": "withSession 미들웨어 연결 중…", + "coordinatorBeat1": "PR 1/2 준비 완료", + "coordinatorBeat2": "PR 2/2 준비 완료" + }, + "steps": { + "name": "오케스트레이션", + "subtitle": "오케스트레이션", + "description": "에이전트가 Orca 워크스페이스를 관리하고 조정하여 더 큰 작업을 수행할 수 있도록 합니다." }, "StatusesPage": { "2f549fc0ba": "src/auth/session.test.ts", @@ -12264,6 +12298,7 @@ "4cc7b83ffe": "파일 필터링...", "21783df79f": "파일 트리 축소", "481e63ca52": "파일", + "resizeFileTree": "파일 트리 크기 조정", "d5ac717d65": "commit 안 됨", "39b6b9e4e4": "브랜치에 커밋됨" }, @@ -12895,6 +12930,9 @@ "bb7f599ee7": "설정 열기", "2d5b9fabf9": "마이크 액세스가 거부되었습니다. 시스템 설정에서 액세스 권한을 부여한 다음 Orca를 다시 시작하세요.", "5d2c3e7ae3": "음성이 감지되지 않았습니다." + }, + "DictationIndicator": { + "335e1bc6cb": "받아쓰기 중지" } }, "dashboard": { @@ -13310,9 +13348,11 @@ "d90981f766": "시간", "6b914c5fbb": "요일", "233b8c94b6": "반복 주기", - "b08ccb4d06": "매시간", - "8cc026fd73": "주간", - "c3e39e17cf": "custom" + "55b2ef82a4": "매시간", + "f0202f3a89": "매일", + "57e83307d0": "평일", + "837d902bba": "매주", + "ddba78647e": "사용자 지정 cron" }, "AutomationSessionField": { "f3c76dce51": "재사용", @@ -14218,12 +14258,12 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "Agent Dashboard settings", - "settingsTooltip": "Board settings" + "settings": "에이전트 대시보드 설정", + "settingsTooltip": "보드 설정" }, "dashboard": { "sidebar": { - "label": "Agent Dashboard" + "label": "에이전트 대시보드" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7f9a1ddfe43..8077f97f6fe 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -333,6 +333,7 @@ "0708ed89f1": "Claude", "fc80296033": "Devin", "da41abbdd4": "Ante", + "060d152fb5": "Trae", "mimo_code_label": "MiMo Code" }, "skill": { @@ -678,6 +679,7 @@ "60428567b4": "当远程运行时处于活动状态时,本地终端显示不可用", "f8aaf2bde3": "工作区已上传", "2fe88c2e06": "远程工作区同步不可用", + "workspaceChangedOnAnotherDevice": "工作区已在另一台设备上更改", "2ec42e1c52": "还没有远程工作区", "88214a785b": "工作区同步等待本地会话恢复(hydration)并超时", "4f78ba5885": "工作区已同步", @@ -767,6 +769,12 @@ }, "useLargeTextControlPaste": { "pasteTooLarge": "粘贴内容过大。" + }, + "useMacosTccPromptNotice": { + "title": "减少重复出现的 macOS 文件访问权限提示", + "description": "macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。", + "openSettings": "打开设置", + "dismiss": "不再显示" } }, "components": { @@ -775,8 +783,8 @@ "9263e75f49": "Codex 正在使用之前的账户", "a4c8e1b2f7": "Codex 仍以 {{value0}} 身份登录", "d3e8a1f4b2": "账户已切换", - "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account" + "9375620cc3": "重启此会话以使用 {{value0}}。在重启之前,它将继续使用旧账户。", + "6133594b12": "保留旧账户" }, "FirstLaunchBanner": { "b9e1b966c7": "关闭通知", @@ -835,7 +843,7 @@ "b1ac991806": "{{value0}} 失败", "checksNeedActionChip": "{{value0}} 个需要操作", "311d0cee55": "{{value0}} 通过", - "e52bed9264": "尚未报告任何检查", + "e52bed9264": "尚未报告任何 CI 检查", "90020cc1f3": "此PR尚未报告检查。", "ecffebc251": "未找到检查", "5dddefdf58": "在 GitHub 中打开", @@ -1329,7 +1337,7 @@ "ae2a34c7b8": "{{value0}} 失败", "checksNeedActionChip": "{{value0}} 个需要操作", "7c5035931a": "{{value0}} 通过", - "a18d01cda3": "尚未报告任何检查", + "a18d01cda3": "尚未报告任何 CI 检查", "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", "85e62c5266": "已启动 AI 智能体处理失败的检查。", @@ -1889,18 +1897,18 @@ "d5253b54af": "错误", "7ffc08506e": "查看", "522df222b9": "旋转器", - "e944c2de43": "Update Verification Blocked", - "5b309b19f3": "Update Wasn't Installed", - "092f09fc14": "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version.", - "c9ff9b9ec2": "Check official releases", - "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", - "5194358929": "Hide details", - "8bc9e17d8f": "Show details", - "8cf17b10af": "Local Build Error", - "a4650b0dc4": "Could Not Use Local Build", - "b1e390250d": "Could not complete the local build switch.", - "d29740d175": "The selected build could not be used.", - "37d45c9ec1": "Choose Another Build" + "e944c2de43": "更新验证被阻止", + "5b309b19f3": "更新未安装", + "092f09fc14": "安装程序的发布者与 Orca 不匹配,因此我们停止了更新。请勿安装此下载;请从官方版本获取正确的版本。", + "c9ff9b9ec2": "查看官方版本", + "a05992a26b": "签名检查无法运行——通常是因为防病毒软件阻止了它。请重试下载,或从我们的官方版本获取安装程序。", + "5194358929": "隐藏详情", + "8bc9e17d8f": "显示详情", + "8cf17b10af": "本地构建错误", + "a4650b0dc4": "无法使用本地构建", + "b1e390250d": "无法完成本地构建切换。", + "d29740d175": "无法使用所选的构建。", + "37d45c9ec1": "选择其他构建" }, "WorktreeJumpPalette": { "ac037cfac2": "移动", @@ -2389,7 +2397,7 @@ "dba753e94f": "删除", "38ca0b1400": "这将永久删除他们的本地文件。您无法撤销此操作。", "c4f4782c02": "不建议", - "0a2e3c7cba": "评审", + "0a2e3c7cba": "查看", "e97e4580c7": "有改动", "9623a5107d": "未推送的 commits", "e8b3741ff7": "被忽略", @@ -2444,7 +2452,7 @@ "5bf2e88480": "已扫描 {{value0}}/{{value1}} 个{{value2}}", "searchPlaceholder": "搜索工作区", "ageFilter": "时间", - "reviewFilter": "评审", + "reviewFilter": "查看", "gitFilter": "Git", "contextFilter": "上下文", "sortBy": "排序依据", @@ -2779,11 +2787,11 @@ "removeWorkspaceButton": "移除工作区" }, "TerminalRemoteRuntimeReconnectBanner": { - "retryingTitle": "Reconnecting to remote runtime", - "disconnectedTitle": "Remote runtime disconnected", - "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.", - "disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.", - "reconnectButton": "Reconnect" + "retryingTitle": "正在重新连接到远程运行时", + "disconnectedTitle": "远程运行时已断开连接", + "retryingBody": "Orca 将重试最多一分钟。如果连接恢复,此终端将恢复。", + "disconnectedBody": "自动重试已停止。重新连接以恢复此终端会话。", + "reconnectButton": "重新连接" } } }, @@ -3046,8 +3054,8 @@ "92924a14e3": "查看非活跃工作区 ({{value0}})", "27a74f91f0": "现在没有任何运行", "1b24a32d3a": "内存", - "298f4be7f2": "中央处理器", - "2aa2de6cb9": "姓名", + "298f4be7f2": "CPU", + "2aa2de6cb9": "名称", "30ff2c3c31": "{{value0}} 个孤立项", "6449a95c78": "Orca 跟踪的进程占用了该计算机的物理 RAM 的大小。", "e7ccce7e87": "系统内存", @@ -3057,10 +3065,10 @@ "93b0de3c21": "重新启动", "f85af9cda6": "资源快照和终端会话不可用。", "f8e0d794b4": "守护进程没有响应", - "bd19fd7a59": "杀死所有会话", + "bd19fd7a59": "终止所有会话", "c9382662bb": "重新启动守护进程", "59f178fe11": "{{value0}},守护进程无法访问", - "21cacb16d1": "· 偏僻的", + "21cacb16d1": "· 远程", "73a3fd68a9": "折叠仓库", "b12e31dfcb": "展开仓库", "d659d71d2d": "恢复工作区 {{value0}}", @@ -3194,7 +3202,7 @@ "a471aa9c24": "已更新", "9be86c46a0": "可自由", "f4d2651498": "已扫描", - "6a5dc3c61a": "评审", + "6a5dc3c61a": "查看", "c361440dc0": "测试版", "8ff597593d": "空间", "0582df6d2e": "扫描", @@ -3219,7 +3227,7 @@ "697d60c456": "清晰可见的选择", "81aaf1de65": "仅显示可删除的工作区", "d7ac56452e": "活动", - "243287ac60": "姓名", + "243287ac60": "名称", "6f8f6a6b04": "搜索工作区", "5caccea440": "删除所选内容", "e4a12c455b": "清除", @@ -3243,7 +3251,7 @@ "ef890d31b9": "全部", "c28643d3da": "转到工作区", "66870929fb": "议题", - "fb2069acb7": "评审", + "fb2069acb7": "查看", "b9b4a3a25d": "分支", "c432278ec7": "编辑器缓冲区", "0bc756efaf": "Git 更改", @@ -3350,17 +3358,17 @@ "gotIt": "Got it" }, "UsageRosterPanel": { - "title": "Usage", - "openDetails": "Open usage details", - "notSignedIn": "not signed in", - "allAgents": "all agents", - "usageDetails": "Usage details & history", - "loadingUsage": "Loading usage…", - "usageUnavailable": "Usage unavailable", - "noUsageData": "No usage data", - "detailed": "Detailed", - "compact": "Compact", - "footerDetailAria": "Usage footer detail", + "title": "使用量", + "openDetails": "查看使用详情", + "notSignedIn": "未登录", + "allAgents": "所有智能体", + "usageDetails": "使用详情与历史", + "loadingUsage": "正在加载使用量…", + "usageUnavailable": "使用量不可用", + "noUsageData": "无使用数据", + "detailed": "详细", + "compact": "精简", + "footerDetailAria": "使用情况页脚详情", "detailedTooltip": "完整使用情况,包含条形图、标签和百分比", "compactTooltip": "精简使用情况:仅显示最吃紧的窗口" }, @@ -3713,7 +3721,7 @@ "ddbcaef7be": "src/渲染器包/ui", "0e9ad9c798": "目录", "064c1e2d12": "渲染器用户界面", - "b3a500c623": "姓名", + "b3a500c623": "名称", "16223dde6a": "加载预设", "a683a4bc8e": "重试加载预设", "14952d451e": "{{value0}} 目录", @@ -4250,7 +4258,7 @@ "2170d553cf": "项目", "b759bb87ee": "需要关注的智能体,然后是最近的活动。", "503462f2b4": "智能体活动", - "3728165cdd": "姓名", + "3728165cdd": "名称", "2a81e07366": "完整列表", "25105b28cb": "紧凑", "d7084e8bc8": "智能体活动", @@ -4989,13 +4997,13 @@ "useDaemonActions": { "01af244097": "取消", "28c8e53176": "这会强制退出所有工作区中每个正在运行的终端窗格。这些会话中所有未保存的工作都会丢失。守护进程本身保持运行,并且可以立即打开新终端。这无法撤销。", - "1bbea41a77": "杀死所有终端会话?", + "1bbea41a77": "终止所有终端会话?", "01d6b7c64e": "终止每个正在运行的终端窗格并重新启动守护进程。窗格显示“进程已退出”并且可以立即重新打开。先前应用程序版本的旧协议会话将被保留。这无法撤销。", "922548bc66": "重新启动终端守护进程?", "2b4efdc162": "无法终止会话。", "d18f3005c2": "{{value0}} 会话{{value1}} 拒绝退出。", "baad8cd651": "没有正在运行的会话。", - "fe2ab66d45": "杀死 {{value1}} 会话中的 {{value0}}。 {{value2}} 拒绝退出。", + "fe2ab66d45": "终止 {{value1}} 会话中的 {{value0}}。 {{value2}} 拒绝退出。", "d762b41f41": "重启失败。", "b5954e12d3": "重新启动失败 - 检查日志。", "0e9da1b98e": "守护进程重新启动。", @@ -5603,8 +5611,8 @@ "e7bb06007c": "局域网", "4a73f5217a": "用于控制其他本地应用程序的脚本的 Apple 事件。", "e119f0d66b": "自动化", - "7ca17b62c8": "当项目、worktree 或符号链接文件会访问 macOS 受保护文件夹时推荐启用。", - "c566bca278": "全磁盘访问", + "7ca17b62c8": "当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。", + "c566bca278": "完全磁盘访问权限", "9f35980756": "击键注入、窗口控制和 UI 自动化工具。", "5b2f22ca2d": "无障碍", "0639db5496": "屏幕截图、视觉自动化和 UI 检查工具。", @@ -5655,15 +5663,15 @@ "defaultViewNative": "Chat UI" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.", - "copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.", - "toggleLabel": "Toggle Agent Dashboard", - "modeLabel": "Open as", - "modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.", - "modeAriaLabel": "Agent Dashboard open mode", - "modeInWindow": "In-window", - "modePopout": "Pop-out" + "title": "智能体仪表盘", + "description": "用于监控跨工作树的智能体的看板,支持窗口内或弹出窗口显示。", + "copy": "在左侧边栏添加智能体仪表盘入口。打开它可以监控关注中、工作中和空闲的智能体,并跳转到其实时终端。", + "toggleLabel": "切换智能体仪表盘", + "modeLabel": "打开方式", + "modeCopy": "将仪表盘显示为侧边栏旁的窗口内看板,或单独的弹出窗口。", + "modeAriaLabel": "智能体仪表盘打开模式", + "modeInWindow": "窗口内", + "modePopout": "弹出窗口" } }, "FloatingWorkspacePane": { @@ -5985,7 +5993,7 @@ "6bf4627168": "取消", "ad9832aa26": "。该面板中未保存的工作将丢失。此操作无法撤销。", "8401328fed": "强制退出", - "87dcafc85c": "杀死这个会话?", + "87dcafc85c": "终止这个会话?", "0b0db4c68c": "终止会话", "d3dba51b15": "正在结束…" }, @@ -6825,7 +6833,7 @@ "fde7ff2cc3": "包/网络共享/ui", "caf33029cc": "目录", "3b6f1abd3e": "例如仅限网络", - "a6fcdd9e3c": "姓名", + "a6fcdd9e3c": "名称", "b9922ec194": "取消预设编辑", "694cc55ecb": "为此存储库创建稀疏工作树时,将使用保存的目录。", "8b64731aaf": "还有 +{{value0}} 个", @@ -7454,7 +7462,7 @@ "9a248333c7": "在左侧边栏顶部显示“任务”按钮。", "155a1e7438": "显示任务按钮", "a895d0f938": "品牌", - "51f957ce39": "姓名", + "51f957ce39": "名称", "36e006efc1": "应用程序", "bed343b03e": "标题栏", "18b4c4c30b": "在标题栏中显示 Orca。", @@ -7493,7 +7501,7 @@ "cb1cc62cf8": "空间", "90bdc043ea": "磁盘", "96b4fb0064": "终端", - "4ddbde4999": "中央处理器", + "4ddbde4999": "CPU", "4355f18ac6": "内存", "9c4d5f0894": "经理", "c690a15849": "资源", @@ -7501,7 +7509,7 @@ "7cf005b29f": "资源管理器", "fe192b060e": "主机", "f4997e0f8a": "联系", - "a278406ed5": "偏僻的", + "a278406ed5": "远程", "6ecad74eb3": "SSH", "f17d66d0d2": "在状态栏显示远程主机连接状态。", "57fb424c56": "远程主机", @@ -7858,8 +7866,8 @@ "grok": "grok" }, "agentDashboard": { - "title": "Agent Dashboard", - "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out." + "title": "智能体仪表盘", + "description": "用于监控跨工作树的智能体的看板,支持窗口内或弹出窗口显示。" } } }, @@ -8532,7 +8540,7 @@ "772e3b4753": "虚拟机", "45501ff2c3": "云", "2bd988d041": "配对码", - "5cd7dca3b8": "偏僻的", + "5cd7dca3b8": "远程", "d760866285": "客户", "09568ccc65": "服务器", "ebd5369acf": "环境", @@ -8582,7 +8590,7 @@ "f7b6383aec": "添加", "62826efbe9": "添加新的远程 SSH 目标。", "f5a691bb6c": "添加 SSH 目标", - "d4bcd497c7": "偏僻的", + "d4bcd497c7": "远程", "74c6d90d78": "管理远程 SSH 目标。", "380a788da7": "SSH 连接" } @@ -8608,7 +8616,7 @@ "search": { "5fb3512e8c": "粘贴", "a38508c419": "复制", - "d106f44fb4": "偏僻的", + "d106f44fb4": "远程", "043b32faa1": "SSH", "9fda309db9": "弗兹夫", "64533e30cc": "尼维姆", @@ -9507,7 +9515,7 @@ "checkAgain": "Check again", "updating": "Updating servers…", "updateAll": "Update {{value0}} servers", - "noUpdates": "No updates available", + "noUpdates": "没有可用更新", "updateOne": "Update server", "downloadProgress": "{{value0}} download progress", "liveTabOne": "1 live tab", @@ -9516,13 +9524,13 @@ "livePanes": "{{value0}} live panes" }, "RemoteServerUpdateStatus": { - "checking": "Checking…", - "available": "Update available", - "current": "Up to date", - "manual": "Manual update", - "offline": "Offline", - "queued": "Queued", - "checkingUpdate": "Checking update…", + "checking": "检查中…", + "available": "有可用更新", + "current": "已是最新", + "manual": "手动更新", + "offline": "离线", + "queued": "已排队", + "checkingUpdate": "正在检查更新…", "downloading": "Downloading…", "restarting": "Restarting…", "updated": "Updated", @@ -9532,37 +9540,37 @@ "legacyHelp": "Update this server manually once to enable remote updates." }, "PluginConsentDialog": { - "workerTrust": "Background worker — runs its own process", - "instructionalTrust": "Instructional content — runs later under user or agent authority", - "declarativeTrust": "Declarative content — no plugin code", - "panelTrust": "Panel or host-integrated content — no worker process", - "trustShortWorker": "Worker", - "trustShortInstructional": "Instructional", - "trustShortPanel": "Panel", - "trustShortDeclarative": "Declarative", - "reviewTitle": "Review plugin", - "title": "Review permissions", - "mixedTitle": "Review access and content", - "instructionalTitle": "Review plugin content", + "workerTrust": "后台工作进程 — 运行自己的进程", + "instructionalTrust": "指令内容 — 在用户或智能体权限下运行", + "declarativeTrust": "声明式内容 — 无插件代码", + "panelTrust": "面板或主机集成内容 — 无工作进程", + "trustShortWorker": "工作进程", + "trustShortInstructional": "指令", + "trustShortPanel": "面板", + "trustShortDeclarative": "声明式", + "reviewTitle": "审查插件", + "title": "审查权限", + "mixedTitle": "审查访问和内容", + "instructionalTitle": "审查插件内容", "subtitle": "{{value0}} v{{value1}} · {{value2}}", - "reconsent": "Permissions, the worker trust tier, or instructional content changed since you last reviewed this plugin. Review it again before it can run.", - "capabilities": "This plugin can", - "warning": "These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes.", - "instructionalWarning": "This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.", - "panelWarning": "These permissions limit how the plugin uses Orca's API. This plugin has no background worker.", - "declarativeWarning": "This plugin contributes validated content only. It does not run a background worker or receive access to Orca's API.", - "keepDisabled": "Keep Disabled", - "enable": "Enable plugin", + "reconsent": "权限、工作进程信任级别或指令内容自您上次审查此插件以来已更改。在运行之前请再次审查。", + "capabilities": "此插件可以", + "warning": "这些权限限制了插件如何使用 Orca 的 API。其工作进程仍作为普通进程在您的计算机上运行,可以完全访问您的文件、网络和其他进程。", + "instructionalWarning": "此插件没有工作进程。其指令内容在您或智能体使用时仍可能引发操作。在启用之前,请审查以下指令和命令。", + "panelWarning": "这些权限限制了插件如何使用 Orca 的 API。此插件没有后台工作进程。", + "declarativeWarning": "此插件仅贡献经过验证的内容。它不运行后台工作进程,也不接收对 Orca API 的访问。", + "keepDisabled": "保持禁用", + "enable": "启用插件", "capability": { - "workspaceRead": "Read the name, branch, and terminal list of your focused worktree", - "terminalSend": "Type text into a terminal you can see (always a specific terminal)", - "notificationsShow": "Show desktop notifications labeled with the plugin name", - "storage": "Store data in the plugin's own storage folder", - "secrets": "Store and read secrets in the plugin's own encrypted vault", - "eventsSubscribe": "Get notified when worktrees are created or removed and when agent status changes", - "settingsOwn": "Read and change the plugin's own settings" + "workspaceRead": "读取您聚焦的工作树的名称、分支和终端列表", + "terminalSend": "在您可见的终端中输入文本(始终是特定终端)", + "notificationsShow": "显示标有插件名称的桌面通知", + "storage": "在插件自己的存储文件夹中存储数据", + "secrets": "在插件自己的加密保险库中存储和读取密钥", + "eventsSubscribe": "在工作树创建或移除以及智能体状态更改时获得通知", + "settingsOwn": "读取和更改插件自己的设置" }, - "decisionFailed": "Could not save the permission decision. Try again." + "decisionFailed": "无法保存权限决定。请重试。" }, "PluginConsentProvenance": { "official": "Official", @@ -9593,9 +9601,9 @@ "title": "Install plugin", "description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.", "source": "Install source", - "localTab": "Local folder", + "localTab": "本地文件夹", "gitTab": "Git URL", - "localLabel": "Plugin folder path", + "localLabel": "插件文件夹路径", "localPlaceholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin", "localHelp": "Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.", "gitLabel": "Repository URL with #ref", @@ -9620,18 +9628,18 @@ "manageSources": "Manage sources", "refreshing": "Refreshing…", "refresh": "Refresh", - "noInstalledTitle": "No plugins installed", - "noInstalled": "Plugins you install appear here.", - "loading": "Loading marketplace plugins…", - "tryAgain": "Try again", - "noSourcesTitle": "No marketplaces configured", - "noSources": "Add an official, community, or private Git marketplace to browse plugins.", - "addSource": "Add marketplace", - "noResultsTitle": "No matching plugins", - "noResults": "No marketplace plugins match this search.", - "clearSearch": "Clear search", - "emptyTitle": "Nothing listed yet", - "empty": "The configured marketplaces do not list any plugins." + "noInstalledTitle": "没有已安装的插件", + "noInstalled": "您安装的插件将显示在此处。", + "loading": "正在加载市场插件…", + "tryAgain": "重试", + "noSourcesTitle": "未配置市场", + "noSources": "添加官方、社区或私有 Git 市场以浏览插件。", + "addSource": "添加市场", + "noResultsTitle": "没有匹配的插件", + "noResults": "没有市场插件匹配此搜索。", + "clearSearch": "清除搜索", + "emptyTitle": "暂无内容", + "empty": "已配置的市场中没有任何插件。" }, "PluginMarketplaceListingRow": { "official": "Official", @@ -9639,146 +9647,146 @@ "noDescription": "No description provided.", "blocked": "Blocked by Orca's safety list: {{value0}}", "blockedAction": "Blocked", - "checkUpdate": "Check for update", + "checkUpdate": "检查更新", "install": "Install" }, "PluginMarketplacePreviewDialog": { - "languagePacksOne": "1 language pack", - "languagePacks": "{{value0}} language packs", - "commandsOne": "1 command", - "commands": "{{value0}} commands", - "keybindingsOne": "1 keyboard shortcut", - "keybindings": "{{value0}} keyboard shortcuts", - "vmRecipesOne": "1 VM recipe", - "vmRecipes": "{{value0}} VM recipes", - "panelsOne": "1 panel", - "panels": "{{value0}} panels", - "eventsOne": "1 event subscription", - "events": "{{value0}} event subscriptions", - "worker": "Background worker", + "languagePacksOne": "1 个语言包", + "languagePacks": "{{value0}} 个语言包", + "commandsOne": "1 个命令", + "commands": "{{value0}} 个命令", + "keybindingsOne": "1 个键盘快捷键", + "keybindings": "{{value0}} 个键盘快捷键", + "vmRecipesOne": "1 个 VM 配方", + "vmRecipes": "{{value0}} 个 VM 配方", + "panelsOne": "1 个面板", + "panels": "{{value0}} 个面板", + "eventsOne": "1 个事件订阅", + "events": "{{value0}} 个事件订阅", + "worker": "后台工作进程", "versionLine": "v{{value0}} · {{value1}}", - "includes": "Includes", - "noContributions": "Manifest metadata only", - "capabilities": "Requested access", - "workerWarning": "Capabilities limit how this plugin uses Orca's API. Its worker still runs as a normal process on this computer with full access to your files, network, and other processes.", - "blocked": "Orca's safety list blocks this plugin: {{value0}}", - "current": "This exact plugin content is already installed.", - "close": "Close", - "cancel": "Cancel", - "update": "Update plugin", - "install": "Install plugin" + "includes": "包含", + "noContributions": "仅清单元数据", + "capabilities": "请求的访问权限", + "workerWarning": "能力限制了此插件如何使用 Orca 的 API。其工作进程仍作为普通进程在此计算机上运行,可以完全访问您的文件、网络和其他进程。", + "blocked": "Orca 的安全列表阻止了此插件:{{value0}}", + "current": "此插件内容已安装。", + "close": "关闭", + "cancel": "取消", + "update": "更新插件", + "install": "安装插件" }, "PluginMarketplaceSourceDialog": { - "addFailed": "Could not add this marketplace. Check the Git URL, ref, and your Git credentials.", - "refreshFailed": "Could not refresh this marketplace. Its last valid cached index is still available.", - "removeFailed": "Could not remove this marketplace.", - "title": "Marketplace sources", - "description": "Marketplaces are pinned Git repositories. Orca uses your existing system Git credentials for private repositories.", + "addFailed": "无法添加此市场。请检查 Git URL、引用和您的 Git 凭据。", + "refreshFailed": "无法刷新此市场。其最后有效的缓存索引仍可用。", + "removeFailed": "无法移除此市场。", + "title": "市场源", + "description": "市场是固定的 Git 仓库。Orca 使用您现有的系统 Git 凭据来访问私有仓库。", "urlLabel": "Git URL", - "urlDescription": "Use an HTTPS or SSH repository URL containing orca-marketplace.json.", + "urlDescription": "使用包含 orca-marketplace.json 的 HTTPS 或 SSH 仓库 URL。", "urlPlaceholder": "https://git.example.com/team/plugins.git", - "refLabel": "Git ref", - "refDescription": "Choose a branch, tag, or commit. Every fetched index is recorded at an exact commit.", - "adding": "Adding…", - "add": "Add source", - "configured": "Configured sources", - "empty": "No marketplace sources configured.", - "official": "Official", - "owner": "Owner: {{value0}}", - "pinnedCommit": "Pinned at {{value0}}", - "stale": "Refresh failed. Browsing the last valid cached index.", - "refreshLabel": "Refresh {{value0}}", - "removeLabel": "Remove {{value0}}", - "done": "Done" + "refLabel": "Git 引用", + "refDescription": "选择分支、标签或提交。每个获取的索引都记录在精确的提交上。", + "adding": "正在添加…", + "add": "添加源", + "configured": "已配置的源", + "empty": "未配置市场源。", + "official": "官方", + "owner": "所有者:{{value0}}", + "pinnedCommit": "固定在 {{value0}}", + "stale": "刷新失败。正在浏览最后有效的缓存索引。", + "refreshLabel": "刷新 {{value0}}", + "removeLabel": "移除 {{value0}}", + "done": "完成" }, "PluginRemoveDialog": { - "title": "Remove plugin?", - "description": "This removes {{value0}} and its stored plugin data from this computer. You can install it again later.", - "cancel": "Cancel", - "remove": "Remove plugin" + "title": "移除插件?", + "description": "这将从计算机中移除 {{value0}} 及其存储的插件数据。您可以稍后再次安装。", + "cancel": "取消", + "remove": "移除插件" }, "PluginRollbackDialog": { - "title": "Roll back plugin?", - "description": "This deactivates {{value0}} and restores its previous immutable version. If that version requests different access or instructional content, Orca will require another review.", - "cancel": "Cancel", - "confirm": "Roll back plugin" + "title": "回滚插件?", + "description": "这将停用 {{value0}} 并恢复其之前的不可变版本。如果该版本请求不同的访问或指令内容,Orca 将要求再次审查。", + "cancel": "取消", + "confirm": "回滚插件" }, "PluginsSettingsSection": { - "systemLabel": "Plugin system", - "systemDescription": "Discovers installed plugins and lets you enable them individually. Nothing runs until you review and enable it. Workers always run on this computer; SSH workspace actions route through Orca.", - "featureOff": "Turn on the plugin system to see and manage installed plugins. Anything already installed stays on disk and stays disabled while the system is off.", - "loading": "Loading plugins…", - "noInstalledResultsTitle": "No matching plugins", - "noInstalledResults": "No installed plugins match this search.", - "emptyTitle": "No plugins installed yet", - "empty": "Browse the All tab to install plugins from a marketplace.", - "loadFailed": "Could not load plugins.", - "settingsUpdateFailed": "Could not save plugin settings.", - "install": "Install plugin", - "title": "Plugins", - "experimental": "Experimental", - "description": "Install and manage Orca plugins. Plugins run on this computer, even for SSH workspaces.", - "logsFailed": "Could not load plugin logs.", - "rollbackFailed": "Could not roll back this plugin. A previous immutable version may not be available." + "systemLabel": "插件系统", + "systemDescription": "发现已安装的插件并允许您单独启用它们。在您审查并启用之前,没有任何内容会运行。工作进程始终在此计算机上运行;SSH 工作区操作通过 Orca 路由。", + "featureOff": "开启插件系统以查看和管理已安装的插件。任何已安装的内容将保留在磁盘上,并在系统关闭时保持禁用。", + "loading": "正在加载插件…", + "noInstalledResultsTitle": "没有匹配的插件", + "noInstalledResults": "没有已安装的插件匹配此搜索。", + "emptyTitle": "尚未安装插件", + "empty": "浏览「全部」选项卡以从市场安装插件。", + "loadFailed": "无法加载插件。", + "settingsUpdateFailed": "无法保存插件设置。", + "install": "安装插件", + "title": "插件", + "experimental": "实验性", + "description": "安装和管理 Orca 插件。插件在此计算机上运行,即使是 SSH 工作区也是如此。", + "logsFailed": "无法加载插件日志。", + "rollbackFailed": "无法回滚此插件。之前的不可变版本可能不可用。" }, "PluginSettingsRow": { - "blocked": "Blocked", - "needsReview": "Needs review", - "restarting": "Restarting", - "invalid": "Invalid", - "error": "Error", - "disabled": "Disabled", - "running": "Running", - "enabled": "Enabled", - "loadingLogs": "Loading logs…", - "noLogs": "No log lines recorded.", - "logCount": "Last {{value0}} of up to 200 retained lines", - "reviewAndEnable": "Review & enable", - "official": "Official", - "dev": "Dev", - "bundled": "Bundled", - "noDescription": "No description provided.", - "killListMessage": "Orca's safety list disabled this plugin: {{value0}}", - "viewAdvisory": "View advisory", - "runtimeError": "The plugin stopped after an activation or worker error.", - "restartCount": " · {{value0}} restarts", - "moreActions": "More actions for {{value0}}", - "hideLogs": "Hide logs", - "viewLogs": "View logs", - "rollback": "Roll back", - "remove": "Remove", - "disableLabel": "Disable {{value0}}", - "enableLabel": "Enable {{value0}}", - "invalidPluginError": "The plugin manifest or installed files are invalid. Fix the plugin, then refresh." + "blocked": "已阻止", + "needsReview": "需要审查", + "restarting": "正在重启", + "invalid": "无效", + "error": "错误", + "disabled": "已禁用", + "running": "运行中", + "enabled": "已启用", + "loadingLogs": "正在加载日志…", + "noLogs": "未记录日志行。", + "logCount": "最近 {{value0}} 行(最多保留 200 行)", + "reviewAndEnable": "审查并启用", + "official": "官方", + "dev": "开发", + "bundled": "捆绑", + "noDescription": "未提供描述。", + "killListMessage": "Orca 的安全列表禁用了此插件:{{value0}}", + "viewAdvisory": "查看公告", + "runtimeError": "插件在激活或工作进程错误后停止。", + "restartCount": " · {{value0}} 次重启", + "moreActions": "{{value0}} 的更多操作", + "hideLogs": "隐藏日志", + "viewLogs": "查看日志", + "rollback": "回滚", + "remove": "移除", + "disableLabel": "禁用 {{value0}}", + "enableLabel": "启用 {{value0}}", + "invalidPluginError": "插件清单或安装的文件无效。请修复插件,然后刷新。" }, "PluginVmRecipeConsentPreview": { - "create": "Create", - "suspend": "Suspend", - "resume": "Resume", - "destroy": "Destroy", - "heading": "VM recipe commands", - "commandLabel": "{{value0}} · {{value1}} command" + "create": "创建", + "suspend": "挂起", + "resume": "恢复", + "destroy": "销毁", + "heading": "VM 配方命令", + "commandLabel": "{{value0}} · {{value1}} 个命令" }, "pluginError": { - "installManifestMissing": "No readable orca-plugin.json was found. Choose the plugin's root folder.", - "installManifestInvalid": "orca-plugin.json is invalid. Ask the plugin author to fix the manifest.", - "incompatible": "This plugin requires a different Orca version.", - "installUnsafePath": "The plugin contains an unsafe file path or symlink and was not installed.", - "installLimit": "The plugin exceeds Orca's install size or file-count limits.", - "installGit": "Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.", - "invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.", - "invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.", - "invalidArtifact": "A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.", - "consentChanged": "The plugin changed while you were reviewing it. Close this dialog and review the updated permissions." + "installManifestMissing": "未找到可读的 orca-plugin.json。请选择插件的根文件夹。", + "installManifestInvalid": "orca-plugin.json 无效。请要求插件作者修复清单。", + "incompatible": "此插件需要不同版本的 Orca。", + "installUnsafePath": "插件包含不安全的文件路径或符号链接,未安装。", + "installLimit": "插件超过 Orca 的安装大小或文件数量限制。", + "installGit": "Orca 无法获取固定的 Git 版本。请检查 URL、#ref、访问权限和系统 Git 设置。", + "invalidManifestMissing": "插件根目录缺少 orca-plugin.json。请添加它,然后刷新插件。", + "invalidManifest": "orca-plugin.json 无效。请修复它,然后刷新插件。", + "invalidArtifact": "声明的工作进程或面板文件缺失或不安全。请修复插件文件,然后刷新。", + "consentChanged": "您在审查时插件发生了更改。请关闭此对话框并审查更新后的权限。" }, "plugins": { "search": { - "title": "Plugins", - "description": "Install and manage experimental Orca plugins.", - "install": "install plugin", - "permissions": "plugin permissions", - "logs": "plugin logs", - "development": "development plugins" + "title": "插件", + "description": "安装和管理实验性 Orca 插件。", + "install": "安装插件", + "permissions": "插件权限", + "logs": "插件日志", + "development": "开发插件" } } }, @@ -10363,7 +10371,7 @@ "74c6885b8a": "更多评论动作", "cbcc4ab3db": "显示前 100 项检查", "0dca6bfab5": "打开检查详细信息", - "991f50c7e4": "尚未报告任何检查", + "991f50c7e4": "尚未报告任何 CI 检查", "9ad98f2a17": "待办的", "5e52f4ef7f": "失败", "02ca4f9074": "通过", @@ -11353,7 +11361,7 @@ "7199ff19c7": "清除选定的来源", "370a1faf67": "在浏览器中打开", "2c69728c2a": "在浏览器中打开链接", - "6f07a18604": "姓名", + "6f07a18604": "名称", "2e4c7c95fe": "分支", "2cfc6be192": "GitLab", "7a47af0565": "Linear", @@ -11381,16 +11389,25 @@ "searchGitLab": "搜索 GitLab MR 和议题", "searchBranches": "搜索分支", "searchLinear": "搜索 Linear 议题", - "workspaceName": "工作区名称" + "workspaceName": "工作区名称", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "此项目没有已就绪的主机。", "placeholder": "选择主机" }, "ProjectCombobox": { - "search": "搜索项目...", "empty": "没有匹配搜索的项目。", - "addProject": "Add a new project" + "addProject": "添加新项目", + "label": "项目", + "browse": "浏览项目", + "listLabel": "项目", + "noProjects": "尚无项目。" + }, + "RunTargetCombobox": { + "listLabel": "运行目标", + "label": "运行在", + "browse": "浏览运行目标" } } }, @@ -11975,8 +11992,25 @@ "agents": { "orchestration": { "OrchestrationPage": { - "30b509a467": "2名儿童", - "862605d066": "2 个子工作区" + "30b509a467": "2 个子工作区", + "862605d066": "2 个子工作区", + "coordinatorName": "重新设计身份验证流程", + "childPr1Name": "PR 1/2: 迁移 users.sql", + "childPr2Name": "PR 2/2: withSession 中间件" + }, + "types": { + "coordinatorInitial": "将身份验证重写拆分为 2 个 PR…", + "codexInitial": "编写 users 表迁移…", + "claudeInitial": "草拟 withSession 中间件…", + "codexBeat1": "添加 email_verified 列…", + "claudeBeat1": "连接 withSession 中间件…", + "coordinatorBeat1": "PR 1/2 就绪", + "coordinatorBeat2": "PR 2/2 就绪" + }, + "steps": { + "name": "编排", + "subtitle": "编排", + "description": "让智能体管理和协调 Orca 工作区,以执行更大的任务。" }, "StatusesPage": { "2f549fc0ba": "src/auth/session.test.ts", @@ -12264,6 +12298,7 @@ "4cc7b83ffe": "搜索文件...", "21783df79f": "折叠文件树", "481e63ca52": "文件", + "resizeFileTree": "调整文件树大小", "d5ac717d65": "未提交", "39b6b9e4e4": "已提交到分支" }, @@ -12274,9 +12309,9 @@ "84898c548d": "清除", "88b70d0ef5": "复制", "bb84b4c374": "AI 笔记", - "948a5fd6c8": "清晰的笔记", + "948a5fd6c8": "清除笔记", "0f806a2ab1": "取消", - "80a286d8f5": "从这个工作树?", + "80a286d8f5": "从此工作树中清除?", "7e7ca60816": "更改的文件", "b6c3b84476": "显示文件树", "39f8007549": "评审冲突", @@ -12648,7 +12683,7 @@ "30099dca46": "文件夹", "2d7d39dc63": ".md", "c8ac7868e6": "文件名", - "b6ed807cc6": "姓名", + "b6ed807cc6": "名称", "e365f3c638": "为您的 Markdown 文件命名并选择一个文件夹。", "674b046582": "另存为" }, @@ -12895,6 +12930,9 @@ "bb7f599ee7": "打开设置", "2d5b9fabf9": "麦克风访问被拒绝。在系统设置中授予访问权限,然后重新启动 Orca。", "5d2c3e7ae3": "未检测到语音。" + }, + "DictationIndicator": { + "335e1bc6cb": "停止听写" } }, "dashboard": { @@ -13310,9 +13348,11 @@ "d90981f766": "时间", "6b914c5fbb": "天", "233b8c94b6": "节奏", - "b08ccb4d06": "每小时", - "8cc026fd73": "每周", - "c3e39e17cf": "风俗" + "55b2ef82a4": "每小时", + "f0202f3a89": "每天", + "57e83307d0": "工作日", + "837d902bba": "每周", + "ddba78647e": "自定义 cron" }, "AutomationSessionField": { "f3c76dce51": "重复利用", @@ -13581,10 +13621,10 @@ "b67e919bd5": "Jira instance type", "17787d6e4b": "Atlassian Cloud", "bc7a831773": "Self-hosted", - "3489e186d6": "Jira site URL", + "3489e186d6": "Jira 站点 URL", "cbc27fa599": "https://jira.example.com", - "730d973bae": "Personal access token", - "8b9c7b9e7b": "Jira personal access token", + "730d973bae": "个人访问令牌", + "8b9c7b9e7b": "Jira 个人访问令牌", "ccfb086d3e": "在 Jira 个人资料中的 Personal Access Tokens 下创建一个个人访问令牌。", "1d947a07ab": "使用自托管 Jira 的基础 URL、用户名和密码来浏览议题。", "f49708c369": "Jira authentication method", @@ -14218,12 +14258,12 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "Agent Dashboard settings", - "settingsTooltip": "Board settings" + "settings": "智能体仪表盘设置", + "settingsTooltip": "看板设置" }, "dashboard": { "sidebar": { - "label": "Agent Dashboard" + "label": "智能体仪表盘" } }, "browser": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index f9536dc1c34..2106c0eaa48 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -104,6 +104,15 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'antigma.ai', homepageUrl: 'https://github.com/AntigmaLabs/ante-preview' }, + { + id: 'trae', + label: translate('auto.lib.agent.catalog.060d152fb5', 'Trae'), + // Why: matches TUI_AGENT_CONFIG.trae.detectCmd, not the ambiguous `trae-cli` — see the Why there. + cmd: 'traecli', + // Why: bare `trae.cn` 404s on Google's favicon service. + faviconDomain: 'www.trae.cn', + homepageUrl: 'https://docs.trae.cn/cli_get-started-with-trae-cli' + }, { id: 'pi', label: translate('auto.lib.agent.catalog.302934c5d9', 'Pi'), diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index fcfaa89c82d..fa30062e332 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -2,6 +2,7 @@ import type { TuiAgent } from '../../../shared/types' import grokUrl from '../../../shared/agent-icons/grok.png?url' import mimoCodeUrl from '../../../shared/agent-icons/mimo-code.png?url' import anteUrl from '../../../shared/agent-icons/ante.png?url' +import traeUrl from '../../../shared/agent-icons/trae.png?url' import geminiUrl from '../../../shared/agent-icons/gemini.png?url' import antigravityUrl from '../../../shared/agent-icons/antigravity.png?url' import gooseUrl from '../../../shared/agent-icons/goose.png?url' @@ -34,6 +35,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { grok: grokUrl, 'mimo-code': mimoCodeUrl, ante: anteUrl, + trae: traeUrl, gemini: geminiUrl, antigravity: antigravityUrl, goose: gooseUrl, diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 5ce6ea89740..8672c681941 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ pasteDraftWhenAgentReady: vi.fn(), seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchPromptFailed: vi.fn() })) @@ -14,12 +15,50 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed }) } })) -import { deliverLaunchPromptToAgentTab } from './agent-launch-prompt-delivery' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab +} from './agent-launch-prompt-delivery' + +describe('seedNativeChatLaunchDraftForAgentTab', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects multi-line text at the helper, not just at the delivery caller', () => { + // Worktree-create and work-item launches seed through this helper directly + // (a Linear draft is always `Linked Linear issue: …\n\n`), so the + // Ctrl+U kill-to-start-of-LINE constraint has to live here. + seedNativeChatLaunchDraftForAgentTab({ + tabId: 'linear-tab', + agent: 'codex', + text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + }) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('seeds single-line text', () => { + seedNativeChatLaunchDraftForAgentTab({ + tabId: 'issue-tab', + agent: 'codex', + text: 'https://github.com/o/r/issues/12' + }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'issue-tab', + agent: 'codex', + text: 'https://github.com/o/r/issues/12', + createdAt: expect.any(Number) + }) + }) +}) describe('deliverLaunchPromptToAgentTab', () => { beforeEach(() => { @@ -55,7 +94,7 @@ describe('deliverLaunchPromptToAgentTab', () => { }) }) - it('does not seed for drafts, unsupported agents, or empty content', async () => { + it('does not seed a launch prompt for drafts, unsupported agents, or empty content', async () => { await deliverLaunchPromptToAgentTab({ tabId: 'draft-tab', agent: 'codex', @@ -81,6 +120,82 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) + it('seeds a native-chat launch draft for supported unsubmitted content', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Review first', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'draft-tab', + agent: 'codex', + text: 'Review first', + createdAt: expect.any(Number) + }) + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + + it('does not seed a launch draft for multi-line content', async () => { + // The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a + // multi-line prefill (e.g. scraped session-fork context) would leave earlier + // lines behind to glue onto the next message. + await deliverLaunchPromptToAgentTab({ + tabId: 'fork-tab', + agent: 'codex', + content: 'Forked from session\n\nhttps://example.test/context', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('does not seed a launch draft for submitted, unsupported, or empty content', async () => { + await deliverLaunchPromptToAgentTab({ + tabId: 'submit-tab', + agent: 'codex', + content: 'Fix failing checks', + submit: true, + forcePaste: true + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'unsupported-tab', + agent: 'gemini', + content: 'Review first', + submit: false, + forcePaste: false + }) + await deliverLaunchPromptToAgentTab({ + tabId: 'empty-tab', + agent: 'claude', + content: ' ', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + it('keeps the seeded launch draft when paste delivery fails', async () => { + // A paste timeout means the TUI never got the draft — the composer copy is + // then the only copy, so it must not be flagged or cleared. + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await deliverLaunchPromptToAgentTab({ + tabId: 'draft-tab', + agent: 'codex', + content: 'Review first', + submit: false, + forcePaste: false + }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalled() + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + it('marks a seeded launch prompt failed when paste delivery returns false', async () => { mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 1569c5f1a92..2f89856bb6a 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -4,6 +4,32 @@ import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/types' +/** Seed the chat-composer copy of launch context that reaches only the TUI + * input (argv prefill or startup paste). No-op for empty text or agents + * without a native-chat renderer. */ +export function seedNativeChatLaunchDraftForAgentTab(args: { + tabId: string + agent: TuiAgent + text: string +}): void { + // Single-line only: the chat send pre-clears the TUI with Ctrl+U + // (kill-to-start-of-LINE), so a multi-line prefill (e.g. a Linear issue block) + // would leave earlier lines to glue onto the next message. + if ( + args.text.trim().length === 0 || + args.text.includes('\n') || + !isNativeChatSupportedAgent(args.agent) + ) { + return + } + useAppStore.getState().seedNativeChatLaunchDraft({ + tabId: args.tabId, + agent: args.agent, + text: args.text, + createdAt: Date.now() + }) +} + export function deliverLaunchPromptToAgentTab(args: { tabId: string agent: TuiAgent @@ -24,6 +50,10 @@ export function deliverLaunchPromptToAgentTab(args: { text: content, createdAt: Date.now() }) + } else if (submit !== true) { + // Why: an unsubmitted draft lives only in the TUI input buffer; seed the + // chat-composer copy so the context isn't invisible in the GUI view. + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) } // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index e9246310881..86b0472c9c4 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../shared/types' +import type { TuiAgent, GlobalSettings } from '../../../shared/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { useAppStore } from '@/store' import { @@ -12,7 +12,6 @@ import { } from '@/components/terminal-pane/terminal-bracketed-paste' import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' -import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 14ac79c4ac8..ce546f541b8 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -860,6 +860,10 @@ describe('formatAgentTypeLabel', () => { expect(formatAgentTypeLabel('ante')).toBe('Ante') }) + it("maps 'trae' to 'Trae'", () => { + expect(formatAgentTypeLabel('trae')).toBe('Trae') + }) + it('passes through arbitrary custom agent names as-is', () => { expect(formatAgentTypeLabel('weirdo')).toBe('weirdo') }) @@ -884,6 +888,7 @@ describe('agentTypeToIconAgent', () => { expect(agentTypeToIconAgent('antigravity')).toBe('antigravity') expect(agentTypeToIconAgent('command-code')).toBe('command-code') expect(agentTypeToIconAgent('ante')).toBe('ante') + expect(agentTypeToIconAgent('trae')).toBe('trae') }) it('returns null for arbitrary non-iconable strings', () => { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index af19128f886..1bfa9fc49da 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -129,7 +129,8 @@ const ICONABLE_AGENT_TYPES: Record = { copilot: true, grok: true, devin: true, - ante: true + ante: true, + trae: true } // Why: return null (not a 'claude' fallback) for unknown so Codex panes don't flash the Claude icon before the hook fires. diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts new file mode 100644 index 00000000000..6a0e64d2b04 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { buildAiVaultDropRepinStartup } from './ai-vault-resume-command' + +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'darwin' +})) + +const RECORDED_HOME = '/tmp/orca/codex-accounts/aaaa/home' +const SELECTED_HOME = '/tmp/orca/codex-accounts/bbbb/home' + +type DropRepinState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projectGroups' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +function makeState(): DropRepinState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'repo-1::worktree-1', + folderWorkspaces: [], + projectGroups: [], + repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], + projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + settings: { + agentDefaultArgs: { claude: '', codex: '' }, + agentDefaultEnv: { claude: {}, codex: {} } + }, + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + } + } as unknown as DropRepinState +} + +function payload(overrides: { + sessionCwd?: string | null + sessionFilePath?: string +}): Parameters[0]['payload'] { + return { + agent: 'codex', + sessionId: 'session-1', + sessionExecutionHostId: 'local', + sessionFilePath: `${RECORDED_HOME}/sessions/2026/07/20/rollout-x.jsonl`, + ...overrides + } +} + +describe('buildAiVaultDropRepinStartup', () => { + it('repins a payload with a cwd to the substituted home', () => { + const startup = buildAiVaultDropRepinStartup({ + state: makeState(), + payload: payload({ sessionCwd: '/Users/ada/repo' }), + substituteCodexHome: SELECTED_HOME, + worktreeId: 'repo-1::worktree-1' + }) + + expect(startup).not.toBeNull() + expect(startup?.command).toContain(`CODEX_HOME='${SELECTED_HOME}'`) + expect(startup?.command).not.toContain(RECORDED_HOME) + expect(startup?.command).toContain("cd '/Users/ada/repo' && ") + }) + + it('repins a payload whose session has no cwd instead of keeping the wrong-account command', () => { + const startup = buildAiVaultDropRepinStartup({ + state: makeState(), + payload: payload({ sessionCwd: null }), + substituteCodexHome: SELECTED_HOME, + worktreeId: 'repo-1::worktree-1' + }) + + expect(startup).not.toBeNull() + expect(startup?.command).toContain(`CODEX_HOME='${SELECTED_HOME}'`) + expect(startup?.command).not.toContain(RECORDED_HOME) + expect(startup?.command).not.toContain('cd ') + }) + + it('declines a payload from an older serializer that never carried sessionCwd', () => { + const startup = buildAiVaultDropRepinStartup({ + state: makeState(), + payload: payload({}), + substituteCodexHome: SELECTED_HOME, + worktreeId: 'repo-1::worktree-1' + }) + + expect(startup).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index f285b3ae3e2..0a9bfbc20ed 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -23,6 +23,7 @@ import { resolveStartupShell } from '../../../shared/tui-agent-startup-shell' import type { AppState } from '@/store/types' +import type { AiVaultSessionDragPayload } from '@/lib/ai-vault-session-drag' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' @@ -82,6 +83,43 @@ export function buildAiVaultResumeStartupForWorktree( return buildAiVaultResumeForWorktree(args) } +/** + * Rebuilds a drag-drop resume startup under the account home the host + * substituted, or null when the payload cannot be repinned. + * + * Why: the drag payload's prebuilt command pins the session's recorded home, + * which the substitution just proved belongs to the wrong account. A null + * sessionCwd is a real value (session has no cwd; rebuild without a cd + * prefix); only an ABSENT sessionCwd — a payload from an older serializer — + * declines, because the original cwd is unrecoverable. + */ +export function buildAiVaultDropRepinStartup(args: { + state: AiVaultResumeWorktreeArgs['state'] + payload: Pick< + AiVaultSessionDragPayload, + 'agent' | 'sessionId' | 'sessionCwd' | 'sessionExecutionHostId' | 'sessionFilePath' + > + substituteCodexHome: string + worktreeId: string +}): AiVaultResumeStartup | null { + if (args.payload.sessionCwd === undefined || !args.payload.sessionFilePath) { + return null + } + return buildAiVaultResumeStartupForWorktree({ + state: args.state, + worktreeId: args.worktreeId, + session: { + agent: args.payload.agent, + sessionId: args.payload.sessionId, + cwd: args.payload.sessionCwd, + codexHome: args.substituteCodexHome, + executionHostId: args.payload.sessionExecutionHostId, + filePath: args.payload.sessionFilePath + }, + commandOverride: args.state.settings?.agentCmdOverrides?.[args.payload.agent] + }) +} + function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { const providerSession = getAiVaultAgentProviderSession(args.session) if ( diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index 161ad6eb5a0..f8af5c5a84f 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -50,6 +50,7 @@ describe('Session History session drag data', () => { command: "cd '/repo' && claude --resume session-1", sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', codexHome: '/Users/ada/Library/Application Support/orca/codex-runtime-home/home', + sessionCwd: '/repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], launchConfig: { @@ -70,6 +71,45 @@ describe('Session History session drag data', () => { expect(readAiVaultSessionDragData(transfer)).toEqual(payload) }) + it('preserves an explicit null sessionCwd across the serialized round-trip', () => { + const transfer = createTransfer() + const payload: AiVaultSessionDragPayload = { + agent: 'codex', + sessionId: 'session-3', + title: 'Session without a recorded cwd', + command: 'codex resume session-3', + sessionFilePath: '/tmp/orca/codex-accounts/a/home/sessions/2026/07/20/rollout-x.jsonl', + codexHome: '/tmp/orca/codex-accounts/a/home', + sessionCwd: null + } + + writeAiVaultSessionDragData(transfer, payload) + + const read = readAiVaultSessionDragData(transfer) + expect(read).toEqual(payload) + // Explicit null (no cwd) must stay distinguishable from an absent key (old serializer). + expect(read && 'sessionCwd' in read).toBe(true) + }) + + it('keeps sessionCwd absent when an older serializer omitted it', () => { + const transfer = createTransfer() + transfer.setData( + AI_VAULT_SESSION_DRAG_TYPE, + JSON.stringify({ + kind: 'ai-vault-session', + version: 1, + agent: 'codex', + sessionId: 'session-4', + title: 'Old-window payload', + command: 'codex resume session-4' + }) + ) + + const read = readAiVaultSessionDragData(transfer) + expect(read).not.toBeNull() + expect(read && 'sessionCwd' in read).toBe(false) + }) + it('rejects blank session file paths', () => { const transfer = createTransfer() transfer.setData( diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index d46d028081f..69c17c5caa6 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -18,6 +18,10 @@ export type AiVaultSessionDragPayload = { sessionFilePath?: string sessionExecutionHostId?: ExecutionHostId codexHome?: string | null + // Why: a per-account repin at drop time rebuilds the startup from the session + // cwd. Explicit null means the session genuinely has no cwd (rebuild without a + // cd prefix); an ABSENT key means an older serializer built the payload. + sessionCwd?: string | null // Why: drag/drop resume must preserve planned env mutations/default args, not just the command. env?: Record envToDelete?: string[] @@ -91,6 +95,9 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD (payload.codexHome === undefined || payload.codexHome === null || isNonEmptyString(payload.codexHome)) && + (payload.sessionCwd === undefined || + payload.sessionCwd === null || + isNonEmptyString(payload.sessionCwd)) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.envToDelete === undefined || isEnvDeletionList(payload.envToDelete)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) && @@ -163,6 +170,7 @@ export function readAiVaultSessionDragData( sessionFilePath, sessionExecutionHostId, codexHome, + sessionCwd, env, envToDelete, launchConfig, @@ -176,6 +184,7 @@ export function readAiVaultSessionDragData( ...(sessionFilePath ? { sessionFilePath } : {}), ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), ...(codexHome !== undefined ? { codexHome } : {}), + ...(sessionCwd !== undefined ? { sessionCwd } : {}), ...(env ? { env } : {}), ...(envToDelete ? { envToDelete } : {}), ...(launchConfig ? { launchConfig } : {}), diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index 92b0199a1d1..2ff2d5be6ab 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -33,17 +33,52 @@ describe('prepareAiVaultSessionForResume', () => { ).rejects.toThrow('Retry resume.') }) - it.each(['/custom/codex', '/tmp/orca/codex-accounts/account-1/home'])( - 'preserves a non-legacy home without materialization: %s', - async (codexHome) => { - const prepareSessionResume = vi.fn() - stubPreparation(prepareSessionResume) - const current = session({ codexHome }) + it('preserves a custom home without materialization', async () => { + const prepareSessionResume = vi.fn() + stubPreparation(prepareSessionResume) + const current = session({ codexHome: '/custom/codex' }) - await expect(prepareAiVaultSessionForResume(current)).resolves.toBe(current) - expect(prepareSessionResume).not.toHaveBeenCalled() - } - ) + await expect(prepareAiVaultSessionForResume(current)).resolves.toBe(current) + expect(prepareSessionResume).not.toHaveBeenCalled() + }) + + it('repins a per-account session to the home the host substitutes', async () => { + const prepareSessionResume = vi.fn().mockResolvedValue({ + useRealCodexHome: false, + substituteCodexHome: '/tmp/orca/codex-accounts/account-2/home' + }) + stubPreparation(prepareSessionResume) + const current = session({ codexHome: '/tmp/orca/codex-accounts/account-1/home' }) + + const prepared = await prepareAiVaultSessionForResume(current) + + expect(prepared.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home') + expect(prepareSessionResume).toHaveBeenCalledWith({ + agent: 'codex', + filePath: current.filePath, + codexHome: current.codexHome, + executionHostId: 'local' + }) + }) + + it('keeps a per-account session unchanged when the host declines to repin', async () => { + stubPreparation(vi.fn().mockResolvedValue({ useRealCodexHome: false })) + const current = session({ codexHome: '/tmp/orca/codex-accounts/account-1/home' }) + + await expect(prepareAiVaultSessionForResume(current)).resolves.toBe(current) + }) + + it('does not ask a remote host to repin a per-account session', async () => { + const prepareSessionResume = vi.fn() + stubPreparation(prepareSessionResume) + const current = session({ + codexHome: '/home/user/.orca/codex-accounts/account-1/home', + executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId'] + }) + + await expect(prepareAiVaultSessionForResume(current)).resolves.toBe(current) + expect(prepareSessionResume).not.toHaveBeenCalled() + }) }) function stubPreparation(prepareSessionResume: ReturnType): void { diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts index d65da5a737e..fc78dbed0c1 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -1,10 +1,14 @@ import type { AiVaultSession } from '../../../shared/ai-vault-types' -import { isLegacySharedCodexHome } from '../../../shared/ai-vault-resume-preparation' +import { + isLegacySharedCodexHome, + isPerAccountManagedCodexHome +} from '../../../shared/ai-vault-resume-preparation' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' export async function prepareAiVaultSessionForResume( session: AiVaultSession ): Promise { - if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) { + if (!aiVaultSessionNeedsResumePreparation(session)) { return session } const result = await window.api.aiVault.prepareSessionResume({ @@ -13,5 +17,28 @@ export async function prepareAiVaultSessionForResume( codexHome: session.codexHome, executionHostId: session.executionHostId }) - return result.useRealCodexHome ? { ...session, codexHome: null } : session + if (result.useRealCodexHome) { + return { ...session, codexHome: null } + } + if (result.substituteCodexHome) { + return { ...session, codexHome: result.substituteCodexHome } + } + return session +} + +export function aiVaultSessionNeedsResumePreparation( + session: Pick +): boolean { + if (session.agent !== 'codex') { + return false + } + if (isLegacySharedCodexHome(session.codexHome)) { + return true + } + // Why: per-account repinning reads the LOCAL account selection, so only + // local sessions ask; remote sessions keep their recorded home untouched. + return ( + isPerAccountManagedCodexHome(session.codexHome) && + (!session.executionHostId || session.executionHostId === LOCAL_EXECUTION_HOST_ID) + ) } diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts new file mode 100644 index 00000000000..a4b1dcefc56 --- /dev/null +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -0,0 +1,491 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { getCodexSelectionLaneKey } from '../../../shared/codex-selection-lane' +import { + getCodexAccountSwitchLaneMatcher, + isForeignMachineCodexPtyId, + isLocalCodexSelectionLaneKey, + resolveCodexPaneSelectionLane, + resolveCodexPaneSelectionLaneKey +} from './codex-pane-selection-lane' + +type LaneState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +function laneState(args?: { + activeRuntimeEnvironmentId?: string | null + worktreePath?: string + folderPath?: string + terminalWindowsShell?: string + terminalWindowsWslDistro?: string | null + projectWslDistro?: string + localWindowsRuntimeDefault?: { kind: 'wsl'; distro: string | null } +}): LaneState { + return { + folderWorkspaces: args?.folderPath ? [{ id: 'fw1', folderPath: args.folderPath }] : [], + settings: { + activeRuntimeEnvironmentId: args?.activeRuntimeEnvironmentId ?? null, + ...(args?.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), + ...(args?.localWindowsRuntimeDefault + ? { localWindowsRuntimeDefault: args.localWindowsRuntimeDefault } + : {}), + terminalWindowsWslDistro: args?.terminalWindowsWslDistro ?? null + }, + repos: [{ id: 'repo1', path: 'C:\\code\\app' }], + projects: args?.projectWslDistro + ? [ + { + id: 'proj1', + sourceRepoIds: ['repo1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: args.projectWslDistro } + } + ] + : [], + worktreesByRepo: { + repo1: [{ id: 'wt1', repoId: 'repo1', path: args?.worktreePath ?? '/Users/dev/code/orca' }] + } + } as unknown as LaneState +} + +/** The Windows-only shell resolution is gated on the renderer platform. */ +function withWindowsRenderer(run: () => void): void { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + configurable: true + }) + try { + run() + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true + }) + } +} + +const HOST_TAB = { worktreeId: 'wt1', shellOverride: undefined } + +describe('resolveCodexPaneSelectionLaneKey', () => { + it('keys an ordinary local pane to the host lane', () => { + expect( + resolveCodexPaneSelectionLaneKey({ state: laneState(), tab: HOST_TAB, ptyId: 'pty-1' }) + ).toBe('host') + }) + + it('keys a pane in a WSL UNC worktree to that distro lane', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + + it('keys a wsl.exe pane outside a UNC worktree to the default WSL lane', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState(), + tab: { worktreeId: 'wt1', shellOverride: 'wsl.exe' }, + ptyId: 'pty-1' + }) + ).toBe('wsl:__default__') + }) + + // Why this matters: pty.ts keys such a pane `wsl:` from the resolved + // runtime, so keying it `wsl:__default__` would make its own distro's switch + // miss it — the pane keeps the old account with no notice. + it('keys a wsl.exe pane on a Windows-path worktree to the configured distro', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + worktreePath: 'C:\\code\\app' + }), + tab: { worktreeId: 'wt1', shellOverride: 'wsl.exe' }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + }) + + it('still keys an ordinary host pane to host when a WSL distro is configured', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ terminalWindowsWslDistro: 'Ubuntu', worktreePath: 'C:\\code\\app' }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('host') + }) + }) + + // Why: main resolves the shell through resolveLocalWindowsTerminalRuntimeOptions, + // so an unset override still lands on WSL when that is the Windows default. + // Reading only the tab would key this pane `host` and mute it on a host switch. + it('keys an override-less pane by the default Windows shell', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + worktreePath: 'C:\\code\\app' + }), + tab: { worktreeId: 'wt1', shellOverride: undefined }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + }) + + // Why: the startup cwd is deliberately unconstrained (#7685), and main keys + // the lane off it, so a pane split across filesystems must follow the cwd. + it('follows the pane startup cwd out of the workspace filesystem', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ worktreePath: 'C:\\code\\app' }), + tab: { + worktreeId: 'wt1', + shellOverride: undefined, + startupCwd: '\\\\wsl.localhost\\Ubuntu\\home\\dev' + }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + + it("keys a pane by its own project's WSL distro", () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ projectWslDistro: 'Debian', worktreePath: 'C:\\code\\app' }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('wsl:Debian') + }) + }) + + // Why: a floating terminal's cwd never reaches the tab, so it is keyed by its + // shell. Pinning that it does not throw or resolve against some other + // workspace's root, which is what the lane would otherwise inherit. + it('keys a floating terminal by its shell, not another workspace root', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\dev' }), + tab: { worktreeId: 'global-floating-terminal', shellOverride: undefined }, + ptyId: 'pty-1' + }) + ).toBe('host') + }) + + // Why: resolveLocalWindowsTerminalRuntimeOptions THROWS on repair-required, + // and this runs outside scanCodexPanes' per-pane failure guard — so without + // the early return the Promise.all rejects and EVERY pane in the batch loses + // its notice, not just this one. + it('answers a repair-required runtime instead of throwing the batch away', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ + localWindowsRuntimeDefault: { kind: 'wsl', distro: null }, + worktreePath: 'C:\\code\\app' + }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('wsl:__default__') + }) + }) + + it('reads the distro from a folder workspace path too', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ folderPath: '\\\\wsl$\\Debian\\srv\\app' }), + tab: { worktreeId: 'folder:fw1', shellOverride: undefined }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Debian') + }) + + it('keys an owned remote runtime pane to its own environment, not the active one', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ activeRuntimeEnvironmentId: 'env-active' }), + tab: HOST_TAB, + ptyId: 'remote:env-owner@@term-1' + }) + ).toBe('env:env-owner') + }) + + it('routes an owner-less remote pane to the active environment, as inspection does', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ activeRuntimeEnvironmentId: 'env-1' }), + tab: HOST_TAB, + ptyId: 'remote:term-1' + }) + ).toBe('env:env-1') + }) + + it('keeps an owner-less remote pane off the host lane when no environment is active', () => { + const laneKey = resolveCodexPaneSelectionLaneKey({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'remote:term-1' + }) + // Why assert disjointness rather than the literal key: colliding with `host` + // is the whole failure mode — a local switch would mute a working remote pane. + expect(laneKey).not.toBe(getCodexSelectionLaneKey({ runtime: 'host' })) + expect(isLocalCodexSelectionLaneKey(laneKey)).toBe(false) + }) + + it('keys an SSH-connection pane to a lane no account selection can name', () => { + const laneKey = resolveCodexPaneSelectionLaneKey({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'ssh:my-box@@pty-7' + }) + expect(laneKey).toBe('ssh-connection') + // Why: managed Codex accounts are only ever 'host' or 'wsl:', so no + // switch can produce this key — the pane is unreachable by any selection. + expect(laneKey).not.toBe(getCodexSelectionLaneKey({ runtime: 'host' })) + expect(isLocalCodexSelectionLaneKey(laneKey)).toBe(false) + }) +}) + +describe('resolveCodexPaneSelectionLane', () => { + it('prefers the lane main recorded at spawn over the current derivation', () => { + // Why this is the whole point: the derivation reads CURRENT state, so the + // user editing a runtime preference after the pane opened must not re-key it. + const lane = resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'wsl:Ubuntu' + }) + expect(lane).toEqual({ + laneKey: 'wsl:Ubuntu', + source: 'recorded', + derivedLaneKey: 'host' + }) + }) + + it('reports the disagreement so a re-derivation bug stays diagnosable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'wsl:Ubuntu' + }) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[codex-lane]'), + expect.objectContaining({ ptyId: 'pty-1', recorded: 'wsl:Ubuntu', derived: 'host' }) + ) + } finally { + warn.mockRestore() + } + }) + + it('stays quiet when the record and the derivation agree', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const lane = resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'host' + }) + expect(lane.source).toBe('recorded') + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + // THE regression that matters: over-filtering silently kills the feature. + it('derives a local host pane that main never recorded', () => { + for (const recordedLaneKey of [undefined, null, '', ' ']) { + expect( + resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey + }) + ).toEqual({ laneKey: 'host', source: 'derived', derivedLaneKey: 'host' }) + } + }) + + it('falls back to the derivation when the record names no selectable lane', () => { + // Why: the registry accepts any string it finds on disk, and a lane key that + // matches no switch would silently drop the pane's notice instead of failing. + expect( + resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'env:env-1' + }) + ).toEqual({ laneKey: 'host', source: 'derived', derivedLaneKey: 'host' }) + }) + + it.each([ + ['remote:env-1@@term-1', 'env:env-1'], + ['ssh:my-box@@pty-7', 'ssh-connection'] + ])('keeps a record from re-keying the foreign pane %s', (ptyId, expectedLaneKey) => { + // Why: a foreign pane's lane is settled by its id, so a record here can only + // be a recycled id — and honouring it would mute a working remote terminal. + expect( + resolveCodexPaneSelectionLane({ + state: laneState({ activeRuntimeEnvironmentId: 'env-1' }), + tab: HOST_TAB, + ptyId, + recordedLaneKey: 'host' + }) + ).toEqual({ laneKey: expectedLaneKey, source: 'derived', derivedLaneKey: expectedLaneKey }) + }) + + it('still answers with the record when the derivation throws', () => { + const exploding = new Proxy(laneState(), { + get(target, property) { + if (property === 'worktreesByRepo') { + throw new Error('state read blew up') + } + return Reflect.get(target, property) + } + }) as LaneState + // Why: this call sits outside the scan's per-pane failure guard, so a throw + // would lose the notice for every pane in the batch, not just this one. + expect( + resolveCodexPaneSelectionLane({ + state: exploding, + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'host' + }) + ).toEqual({ laneKey: 'host', source: 'recorded', derivedLaneKey: null }) + }) +}) + +describe('getCodexAccountSwitchLaneMatcher', () => { + it('scopes a local switch to the runtime slot it wrote', () => { + const hostSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'host' } + }) + expect(hostSwitch('host')).toBe(true) + expect(hostSwitch('wsl:Ubuntu')).toBe(false) + + const ubuntuSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + expect(ubuntuSwitch('wsl:Ubuntu')).toBe(true) + expect(ubuntuSwitch('wsl:Debian')).toBe(false) + expect(ubuntuSwitch('host')).toBe(false) + }) + + // Why a family: clearing a distro-less WSL selection nulls EVERY wsl slot, so + // matching only `wsl:__default__` would leave those panes stranded, unnoticed. + it('claims every WSL distro when the change cleared them all', () => { + const wslDefaultSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: null }, + clearsEveryWslDistro: true + }) + expect(wslDefaultSwitch('wsl:__default__')).toBe(true) + expect(wslDefaultSwitch('wsl:Ubuntu')).toBe(true) + expect(wslDefaultSwitch('wsl:Debian')).toBe(true) + // Still cannot reach another machine, which is the point of the guard. + expect(wslDefaultSwitch('host')).toBe(false) + expect(wslDefaultSwitch('env:env-1')).toBe(false) + expect(wslDefaultSwitch('ssh-connection')).toBe(false) + expect(wslDefaultSwitch('remote-runtime')).toBe(false) + }) + + // Why the negative half matters: pointing a distro-less WSL row at a real + // account writes only the `__default__` slot, so claiming the family there + // would card — and mute — every sibling distro's healthy Codex pane. + it('keeps a distro-less WSL selection off sibling distro panes', () => { + const wslDefaultSelect = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: null } + }) + expect(wslDefaultSelect('wsl:__default__')).toBe(true) + expect(wslDefaultSelect('wsl:Ubuntu')).toBe(false) + expect(wslDefaultSelect('host')).toBe(false) + }) + + // Why: StatusBar passes clearsEveryWslDistro for the "System default" row of + // EVERY WSL group, including a concrete-distro one, where the clear lands in + // that slot alone. Without the null-distro condition it would mute them all. + it('keeps a cleared concrete-distro row off the other distros', () => { + const ubuntuClear = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + clearsEveryWslDistro: true + }) + expect(ubuntuClear('wsl:Ubuntu')).toBe(true) + expect(ubuntuClear('wsl:Debian')).toBe(false) + expect(ubuntuClear('wsl:__default__')).toBe(false) + }) + + it('scopes a switch made against a runtime environment to that machine', () => { + const environmentSwitch = getCodexAccountSwitchLaneMatcher({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { runtime: 'host' } + }) + expect(environmentSwitch('env:env-1')).toBe(true) + expect(environmentSwitch('host')).toBe(false) + expect(environmentSwitch('env:env-2')).toBe(false) + }) + + it('never lets a local host switch claim a remote or SSH pane', () => { + const hostSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'host' } + }) + const state = laneState() + for (const ptyId of ['remote:env-owner@@term-1', 'remote:term-1', 'ssh:my-box@@pty-7']) { + expect(hostSwitch(resolveCodexPaneSelectionLaneKey({ state, tab: HOST_TAB, ptyId }))).toBe( + false + ) + } + }) +}) + +describe('isForeignMachineCodexPtyId', () => { + it('separates panes whose shell runs on another machine from local ones', () => { + expect(isForeignMachineCodexPtyId('remote:env-1@@term-1')).toBe(true) + expect(isForeignMachineCodexPtyId('remote:term-1')).toBe(true) + expect(isForeignMachineCodexPtyId('ssh:my-box@@pty-7')).toBe(true) + expect(isForeignMachineCodexPtyId('pty-1')).toBe(false) + }) + + it('agrees with the lane keys, so the sweep and the scan skip the same panes', () => { + const state = laneState({ activeRuntimeEnvironmentId: 'env-1' }) + for (const ptyId of ['remote:env-1@@term-1', 'remote:term-1', 'ssh:my-box@@pty-7', 'pty-1']) { + expect( + isLocalCodexSelectionLaneKey( + resolveCodexPaneSelectionLaneKey({ state, tab: HOST_TAB, ptyId }) + ) + ).toBe(!isForeignMachineCodexPtyId(ptyId)) + } + }) +}) diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts new file mode 100644 index 00000000000..e6c8a6877da --- /dev/null +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -0,0 +1,304 @@ +import type { AppState } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { + getCodexSelectionLaneKey, + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget +} from '../../../shared/codex-selection-lane' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions, + type LocalWindowsTerminalRuntimeOptions +} from '../../../shared/local-windows-terminal-runtime' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { resolveTerminalStartupCwd } from '../../../shared/terminal-startup-cwd' +import type { GlobalSettings, TerminalTab } from '../../../shared/types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import { getLocalProjectExecutionRuntimeContext } from './local-preflight-context' +import { getRendererAppPlatform } from './renderer-app-platform' +import { + getCachedWindowsTerminalCapabilities, + hasCachedWindowsTerminalCapabilities +} from './windows-terminal-capabilities' + +type RuntimeEnvironmentSettings = Pick + +/** Everything the pane lane needs: the workspace path plus the project runtime inputs. */ +type CodexPaneLaneState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +/** + * Lane keys for panes whose Codex credentials come from another machine. + * + * Why they need keys at all: a managed Codex account is scoped to one machine + * AND one runtime (`host` or `wsl:`). A relay environment keeps its own + * account roster, and an SSH connection has no Orca-managed selection whatsoever + * — the remote Codex reads that machine's own credentials. Neither can be + * stranded by a local selection change, so they must not share the local keys. + */ +const RUNTIME_ENVIRONMENT_LANE_PREFIX = 'env:' +const SSH_CONNECTION_LANE_KEY = 'ssh-connection' +const UNATTRIBUTED_REMOTE_LANE_KEY = 'remote-runtime' +const HOST_LANE_KEY = 'host' +const WSL_LANE_PREFIX = 'wsl:' + +/** True for the lanes an on-disk pane-account record can name. */ +export function isLocalCodexSelectionLaneKey(laneKey: string): boolean { + return laneKey === HOST_LANE_KEY || laneKey.startsWith(WSL_LANE_PREFIX) +} + +/** + * True when the pane's shell runs on a machine other than this one. + * + * Why it takes only the id: a `remote:`/`ssh:` prefix is assigned at spawn and + * is decisive on its own, so callers with no store access (the bind-driven + * sweep) can skip these panes before spending a 15s RPC on them. + */ +export function isForeignMachineCodexPtyId(ptyId: string): boolean { + return parseRemoteRuntimePtyId(ptyId) !== null || parseAppSshPtyId(ptyId) !== null +} + +/** Matches the panes a Codex account mutation could have re-pointed. */ +export function getCodexAccountSwitchLaneMatcher(args: { + settings: RuntimeEnvironmentSettings | null | undefined + target?: CodexAccountSelectionTarget | null + /** + * True only when the mutation cleared every WSL distro slot at once, which + * setSelectedCodexAccountIdForTarget does for a null account on a distro-less + * WSL target. Any other write lands in a single slot, so defaulting this to + * false keeps the matcher from muting a sibling distro's healthy panes. + */ + clearsEveryWslDistro?: boolean +}): (laneKey: string) => boolean { + const runtimeTarget = getActiveRuntimeTarget(args.settings) + // Why: with an environment active the mutation is RPC'd to that machine's + // roster and local GlobalSettings are never touched, so the local host/WSL + // panes are exactly the ones the switch cannot have affected. + if (runtimeTarget.kind === 'environment') { + const environmentLaneKey = `${RUNTIME_ENVIRONMENT_LANE_PREFIX}${runtimeTarget.environmentId}` + return (laneKey) => laneKey === environmentLaneKey + } + const normalized = normalizeCodexAccountSelectionTarget(args.target) + // Why a family rather than the `wsl:__default__` key: clearing a distro-less + // WSL selection nulls every distro slot, so every WSL pane really is stranded. + // Keying that to `__default__` alone would leave them all without a notice. + if (args.clearsEveryWslDistro && normalized.runtime === 'wsl' && normalized.wslDistro === null) { + return (laneKey) => laneKey.startsWith(WSL_LANE_PREFIX) + } + const switchLaneKey = getCodexSelectionLaneKey(normalized) + return (laneKey) => laneKey === switchLaneKey +} + +export type CodexPaneSelectionLane = { + /** The lane the caller must filter on. */ + laneKey: string + /** Which answer won: main's spawn-time record, or the renderer's re-derivation. */ + source: 'recorded' | 'derived' + /** What the derivation said, or null when it threw. Diagnostics only. */ + derivedLaneKey: string | null +} + +/** + * The lane a pane launched from, preferring the one main recorded at spawn. + * + * Why recorded wins: main writes `selectionKey` from the shell, cwd and distro + * the spawn actually resolved, so it cannot drift. The derivation below reads + * CURRENT state, so flipping the global WSL distro or a project's runtime + * preference after a pane opened makes it answer for a launch that never + * happened — a missed notice one way, a muted working terminal the other. + * + * The derivation stays because it is the only answer for the panes main never + * records: every pre-feature pane, and every LocalPtyProvider or remote spawn. + */ +export function resolveCodexPaneSelectionLane(args: { + state: CodexPaneLaneState + tab: Pick + ptyId: string + /** The pane's `selectionKey` from the on-disk registry, when it has one. */ + recordedLaneKey?: string | null +}): CodexPaneSelectionLane { + const recorded = args.recordedLaneKey?.trim() + // Why the local-key check: the registry accepts any string it finds on disk, + // and a lane key that matches no switch silently drops that pane's notice. + // Why foreign ids still derive: their lane is settled by the id itself and no + // record can exist for one, so a hit here would mean a recycled id. + const trustsRecord = + Boolean(recorded) && + isLocalCodexSelectionLaneKey(recorded as string) && + !isForeignMachineCodexPtyId(args.ptyId) + if (!trustsRecord) { + const laneKey = resolveCodexPaneSelectionLaneKey(args) + return { laneKey, source: 'derived', derivedLaneKey: laneKey } + } + const derivedLaneKey = deriveLaneKeyForDiagnostics(args) + if (derivedLaneKey !== null && derivedLaneKey !== recorded) { + // Why loud: every divergence found in review was this exact disagreement, + // and the recorded key now hides it instead of producing a visible bug. + console.warn('[codex-lane] recorded launch lane disagrees with the derived one:', { + ptyId: args.ptyId, + recorded, + derived: derivedLaneKey + }) + } + return { laneKey: recorded as string, source: 'recorded', derivedLaneKey } +} + +/** Never let the diagnostic derivation break a pane whose lane is already known. */ +function deriveLaneKeyForDiagnostics(args: { + state: CodexPaneLaneState + tab: Pick + ptyId: string +}): string | null { + try { + return resolveCodexPaneSelectionLaneKey(args) + } catch { + return null + } +} + +/** The lane a live pane resolves its Codex account from, re-derived from state. */ +export function resolveCodexPaneSelectionLaneKey(args: { + state: CodexPaneLaneState + tab: Pick + ptyId: string +}): string { + const remoteParts = parseRemoteRuntimePtyId(args.ptyId) + if (remoteParts !== null) { + const runtimeTarget = getActiveRuntimeTarget(args.state.settings) + // Why: mirror inspectRuntimeTerminalProcess — an owner-less remote id is + // routed to whichever environment is active, so that is its lane too. + const environmentId = + remoteParts.environmentId?.trim() || + (runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null) + return environmentId + ? `${RUNTIME_ENVIRONMENT_LANE_PREFIX}${environmentId}` + : UNATTRIBUTED_REMOTE_LANE_KEY + } + if (parseAppSshPtyId(args.ptyId) !== null) { + return SSH_CONNECTION_LANE_KEY + } + return getCodexSelectionLaneKey(resolveLocalPaneSelectionTarget(args)) +} + +/** + * Mirrors the main-process getCodexSelectionTargetForPty, from renderer state. + * + * Why the pane cwd and not the workspace root: a terminal's startup cwd is + * deliberately NOT constrained to the worktree (see resolveTerminalStartupCwd, + * #7685), so a pane split after `cd \\wsl.localhost\...` runs on a different + * filesystem than its workspace. Main keys the lane off that cwd, so reading the + * root instead would call a live WSL pane `host` and mute it on a host switch. + */ +function resolveLocalPaneSelectionTarget(args: { + state: CodexPaneLaneState + tab: Pick +}): CodexAccountSelectionTarget { + const paneCwd = resolvePaneCwd(args) + const wslPath = paneCwd ? parseWslUncPath(paneCwd) : null + if (wslPath) { + return { runtime: 'wsl', wslDistro: wslPath.distro } + } + const terminalRuntime = resolveLocalPaneTerminalRuntime(args) + if (isWslShellName(terminalRuntime.shellOverride)) { + return { runtime: 'wsl', wslDistro: terminalRuntime.terminalWindowsWslDistro } + } + return { runtime: 'host' } +} + +/** The absolute directory the pane's shell was spawned in, as main resolved it. */ +function resolvePaneCwd(args: { + state: CodexPaneLaneState + tab: Pick +}): string | null { + // Why floating terminals get no cwd: theirs never reaches the tab. It is + // resolved over IPC from settings.floatingTerminalCwd and handed to the + // transport as a prop, so the store cannot see the path main keyed off. Such a + // pane falls through to its shell below, which is right unless the configured + // floating cwd is a WSL UNC path under a host shell — a known gap, not a guess + // worth making, since guessing wrong here mutes a working terminal. + if (args.tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return null + } + const workspacePath = getWorkspacePath(args.state, args.tab.worktreeId) + if (!workspacePath) { + return null + } + // Why this exact call: it is the same one main spawns through, so a relative + // or inherited startup folder resolves to the identical absolute path. + return resolveTerminalStartupCwd(workspacePath, args.tab.startupCwd) ?? workspacePath +} + +/** + * The shell and distro the launch resolved, not merely the ones the tab asked for. + * + * Why getLocalProjectExecutionRuntimeContext specifically: for a local pane the + * RENDERER computes the project runtime and ships it with the spawn + * (pty-connection.ts), so this is not an approximation of main — it is the same + * call on the same state. Re-deriving it by hand drops the global WSL default, + * which turns `inherit-global` into WSL and would key a live WSL pane `host`. + */ +function resolveLocalPaneTerminalRuntime(args: { + state: CodexPaneLaneState + tab: Pick +}): LocalWindowsTerminalRuntimeOptions { + // Why the platform gate: pty.ts only consults the Windows terminal runtime on + // win32, so elsewhere the tab's own override is the whole answer. + if (getRendererAppPlatform() !== 'win32') { + return { shellOverride: args.tab.shellOverride, terminalWindowsWslDistro: null } + } + const capabilities = hasCachedWindowsTerminalCapabilities() + ? getCachedWindowsTerminalCapabilities() + : null + const projectRuntime = getLocalProjectExecutionRuntimeContext( + args.state, + args.tab.worktreeId, + undefined, + { + wslAvailable: capabilities?.wslAvailable, + availableWslDistros: capabilities?.wslDistros ?? null + } + ) + if (projectRuntime?.status === 'repair-required') { + // Why not delegate: resolveLocalWindowsTerminalRuntimeOptions throws here, + // and this call sits outside the scan's per-pane failure guard, so a throw + // would lose the notice for every pane in the batch, not just this one. + return { + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: projectRuntime.repair.preferredRuntime.distro + } + } + return resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.tab.shellOverride, + settings: args.state.settings ?? undefined, + projectRuntime + }) +} + +function getWorkspacePath( + state: Pick, + worktreeId: string +): string | null { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type === 'folder') { + return ( + (state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId) + ?.folderPath ?? null + ) + } + return ( + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((entry) => entry.id === worktreeId)?.path ?? null + ) +} diff --git a/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts new file mode 100644 index 00000000000..c913cfe060d --- /dev/null +++ b/src/renderer/src/lib/codex-session-restart-shell-flap.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + markLiveCodexSessionsForRestart, + markRestoredStaleCodexSessionsForRestart +} from './codex-session-restart' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' + +const ACCOUNT_A = 'account-a@example.com' +const ACCOUNT_B = 'account-b@example.com' + +/** + * The cached inspection can flap to the pane's shell for a live Codex session + * (#11064), and the switch path has no retry — one spurious reading silently + * loses the restart card forever. These pin the fresh-scan re-confirmation. + */ +describe('spurious shell readings on Codex-launched panes', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + function seedPane(args: { ptyId?: string; launchAgent?: 'codex' } = {}): void { + const ptyId = args.ptyId ?? 'pty-1' + useAppStore.setState({ + settings: null as never, + tabsByWorktree: { + wt1: [ + { + id: 'tab-1', + ptyId, + worktreeId: 'wt1', + title: 'orca-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) + } + ] + }, + ptyIdsByTabId: { 'tab-1': [ptyId] }, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {} + }) + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + seedPane({ launchAgent: 'codex' }) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + inspectProcess: vi + .fn() + .mockResolvedValue({ foregroundProcess: 'zsh', hasChildProcesses: false }), + confirmForegroundProcess: vi.fn().mockResolvedValue(null) + }, + codexAccounts: { + ...originalWindow?.api?.codexAccounts, + list: vi.fn().mockResolvedValue({ + accounts: [ + { id: 'account-a', email: ACCOUNT_A }, + { id: 'account-b', email: ACCOUNT_B } + ], + activeAccountId: 'account-b' + }), + listStalePanes: vi.fn().mockResolvedValue([]) + }, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeEnvironmentTransportCall + } + } + } as unknown as typeof window + }) + + afterEach(() => { + useAppStore.setState({ settings: null as never }) + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('re-confirms with the fresh scan and cards the pane when codex still owns it', async () => { + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex-aarch64-ap') + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith('pty-1') + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) + + it('keeps the pane uncarded when the fresh scan confirms the shell (user exited Codex)', async () => { + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('treats a rejected confirmation as no new evidence', async () => { + vi.mocked(window.api.pty.confirmForegroundProcess).mockRejectedValue(new Error('daemon busy')) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('survives a preload without confirmForegroundProcess', async () => { + ;( + window.api.pty as unknown as { confirmForegroundProcess?: unknown } + ).confirmForegroundProcess = undefined + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('never spends the fresh scan on a pane Orca did not launch Codex in', async () => { + seedPane() + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalled() + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('never spends the fresh scan when the cached reading already answers', async () => { + vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({ + foregroundProcess: 'codex', + hasChildProcesses: true + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeDefined() + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('never routes a remote runtime pane through the local confirm bridge', async () => { + seedPane({ ptyId: 'remote:term-1', launchAgent: 'codex' }) + useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { process: { foregroundProcess: 'zsh', hasChildProcesses: false } }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + // Why: a fresh local scan can know nothing about env-1's PTY, and a + // cross-lane answer must never card (or spare) a remote pane. + expect(window.api.pty.confirmForegroundProcess).not.toHaveBeenCalled() + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('heals a spurious shell reading before the restored-pane stale lookup', async () => { + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex') + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + await markRestoredStaleCodexSessionsForRestart() + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + }) +}) diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index 7e03475bdcb..91eca6923e3 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -48,6 +48,9 @@ describe('markLiveCodexSessionsForRestart', () => { return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) }) useAppStore.setState({ + // Why: a prior test's remote-runtime selection must not silently move + // every later local pane out of the switch's lane. + settings: null as never, tabsByWorktree: { wt1: [ { @@ -78,7 +81,8 @@ describe('markLiveCodexSessionsForRestart', () => { ...originalWindow?.api?.pty, getForegroundProcess: vi.fn(), hasChildProcesses: vi.fn().mockResolvedValue(false), - inspectProcess: vi.fn() + inspectProcess: vi.fn(), + confirmForegroundProcess: vi.fn().mockResolvedValue(null) }, codexAccounts: { ...originalWindow?.api?.codexAccounts, @@ -416,6 +420,316 @@ describe('markLiveCodexSessionsForRestart', () => { }) }) +/** + * A restart notice blocks every keystroke in the pane it names, so raising one + * on a pane the switch could not have touched takes a working terminal deaf. + * A managed Codex account is scoped to one machine AND one runtime: a remote + * spawn carries a connectionId, so no CODEX_HOME is ever injected into it, and + * WSL selections live in their own per-distro slot. + */ +describe('markLiveCodexSessionsForRestart lane scoping', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + function seedPanes( + panes: { ptyId: string; worktreeId?: string; shellOverride?: string }[], + worktreePaths: Record = {} + ): void { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + worktreesByRepo: { + repo1: [ + { id: 'wt1', path: worktreePaths.wt1 ?? '/Users/dev/code/orca' }, + ...(worktreePaths.wt2 ? [{ id: 'wt2', path: worktreePaths.wt2 }] : []) + ] + } as never, + tabsByWorktree: { + wt1: panes.map((pane, index) => ({ + id: `tab-${index}`, + ptyId: pane.ptyId, + worktreeId: pane.worktreeId ?? 'wt1', + title: `orca-${index}`, + customTitle: null, + color: null, + sortOrder: index, + createdAt: 1, + launchAgent: 'codex' as const, + ...(pane.shellOverride ? { shellOverride: pane.shellOverride } : {}) + })) + }, + ptyIdsByTabId: Object.fromEntries(panes.map((pane, index) => [`tab-${index}`, [pane.ptyId]])), + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {} + }) + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + // Why: every pane in this block reads as a live Codex session, so any pane + // left uncarded was excluded by its lane and nothing else. + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { process: { foregroundProcess: 'codex', hasChildProcesses: true } }, + _meta: { runtimeId: 'remote-runtime' } + }) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + inspectProcess: vi + .fn() + .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: true }) + }, + codexAccounts: { + ...originalWindow?.api?.codexAccounts, + list: vi.fn().mockResolvedValue({ accounts: [], activeAccountId: null }), + listStalePanes: vi.fn().mockResolvedValue([]), + listRecordedPaneLanes: vi.fn().mockResolvedValue({}) + }, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeEnvironmentTransportCall + } + } + } as unknown as typeof window + }) + + afterEach(() => { + useAppStore.setState({ settings: null as never, worktreesByRepo: {} as never }) + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('leaves a live remote Codex pane alone on a host switch, and never inspects it', async () => { + seedPanes([{ ptyId: 'remote:env-1@@term-1' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('leaves a live SSH-connection Codex pane alone on a host switch', async () => { + seedPanes([{ ptyId: 'ssh:my-box@@pty-7' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + it('still marks the local host pane while sparing the remote one beside it', async () => { + seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-1') + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('still marks a local host pane when the switch names no target at all', async () => { + seedPanes([{ ptyId: 'pty-1' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + }) + + // Why these two: a Windows validation saw a WSL pane escape a host switch, but + // only because its foreground read as `wsl.exe` and failed the Codex test. Pin + // the lane instead — a WSL pane whose foreground IS codex must escape too. + it('leaves a WSL Codex pane alone on a host switch even when its foreground is codex', async () => { + seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + it('marks that same WSL pane when its own distro is the lane that changed', async () => { + seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-wsl': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + }) + + it('keeps one distro switch off another distro pane', async () => { + seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'wsl', wslDistro: 'Debian' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('leaves the local host pane alone when the switch was made on a runtime environment', async () => { + seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) + useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + // Why: that mutation was RPC'd to env-1's own roster, so env-1's panes are + // the stale ones and the local shell is untouched — the mirror of the bug. + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'remote:env-1@@term-1': { + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + } + }) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + /** + * Main writes the lane from the shell, cwd and distro the spawn resolved, so + * it is exact where re-deriving from current state can only approximate. Four + * review rounds each found another divergence in that derivation; these pin + * the record beating it in both directions. + */ + describe('recorded launch lanes', () => { + it('spares a pane the record puts in another lane, and never inspects it', async () => { + // Derivation says `host`; the pane really launched under WSL. Carding it + // would take a working terminal deaf — this is the bug class in one test. + seedPanes([{ ptyId: 'pty-1' }]) + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue({ + 'pty-1': 'wsl:Ubuntu' + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + it('cards a pane the record puts in the switched lane against the derivation', async () => { + // The mirror: the user changed a runtime preference after this WSL-looking + // pane spawned on the host, and re-derivation would now miss its notice. + seedPanes([{ ptyId: 'pty-1' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue({ + 'pty-1': 'host' + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) + + // THE regression check: over-filtering silently kills the feature and reads + // as a pass. A genuine local host pane must be carded on every fallback path. + it.each([ + ['no record exists for the pane', () => ({ 'pty-other': 'wsl:Ubuntu' })], + ['the lookup rejects', null], + ['the preload predates the lookup', undefined] + ])('still cards a local host pane when %s', async (_label, recorded) => { + seedPanes([{ ptyId: 'pty-1' }]) + if (recorded === null) { + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockRejectedValue( + new Error('no handler') + ) + } else if (recorded === undefined) { + ;( + window.api.codexAccounts as unknown as { listRecordedPaneLanes?: unknown } + ).listRecordedPaneLanes = undefined + } else { + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue(recorded()) + } + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) + + it('asks only about panes main could have recorded', async () => { + seedPanes([ + { ptyId: 'pty-1' }, + { ptyId: 'remote:env-1@@term-1' }, + { ptyId: 'ssh:my-box@@pty-7' } + ]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + // Why: main only records daemon host spawns, so a foreign id is a certain + // miss — and one batched call, not one per pane. + expect(window.api.codexAccounts.listRecordedPaneLanes).toHaveBeenCalledTimes(1) + expect(window.api.codexAccounts.listRecordedPaneLanes).toHaveBeenCalledWith({ + ptyIds: ['pty-1'] + }) + }) + }) +}) + describe('markRestoredStaleCodexSessionsForRestart', () => { const originalWindow = (globalThis as { window?: typeof window }).window @@ -450,7 +764,8 @@ describe('markRestoredStaleCodexSessionsForRestart', () => { hasChildProcesses: vi.fn().mockResolvedValue(false), inspectProcess: vi .fn() - .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false }) + .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false }), + confirmForegroundProcess: vi.fn().mockResolvedValue(null) }, codexAccounts: { ...originalWindow?.api?.codexAccounts, @@ -485,7 +800,9 @@ describe('markRestoredStaleCodexSessionsForRestart', () => { expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index ddde138f48b..9d1faeb2611 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -1,8 +1,24 @@ import type { AppState } from '@/store' import { useAppStore } from '@/store' -import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { + confirmRuntimeTerminalForegroundProcess, + inspectRuntimeTerminalProcess, + type RuntimeTerminalProcessInspection +} from '@/runtime/runtime-terminal-inspection' import { translate } from '@/i18n/i18n' -import { isCodexRestartEligiblePane } from './codex-pane-restart-eligibility' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { + isCodexForegroundProcess, + isCodexRestartEligiblePane +} from './codex-pane-restart-eligibility' +import { + getCodexAccountSwitchLaneMatcher, + isForeignMachineCodexPtyId, + isLocalCodexSelectionLaneKey, + resolveCodexPaneSelectionLane +} from './codex-pane-selection-lane' +import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane' +import type { TuiAgent } from '../../../shared/types' // Why: prompt integrations such as Starship can outlast the daemon's 300ms // Codex fast-path timeout; account restarts must wait until the shell accepts input. @@ -21,52 +37,173 @@ export type CodexPaneScanResult = { launchedCodex: boolean /** A restart notice was raised for this pane by this scan. */ notified: boolean + /** The lane this pane was filtered on. */ + laneKey: string + /** Whether that lane came from main's spawn record or the renderer's derivation. */ + laneSource: 'recorded' | 'derived' } +/** + * Asks main which lane each pane actually launched from. + * + * Why failure is silent: the answer only upgrades the derivation's accuracy, so + * an older preload, a web client, or a missing registry must degrade to the + * derivation rather than lose every pane's restart notice. + */ +async function readRecordedCodexPaneLanes( + ptyIds: readonly string[] +): Promise> { + // Why filtered: main only records daemon host spawns, so asking about a + // remote or SSH pane is a guaranteed miss. + const localPtyIds = ptyIds.filter((ptyId) => !isForeignMachineCodexPtyId(ptyId)) + if (localPtyIds.length === 0) { + return {} + } + const listRecordedPaneLanes = window.api.codexAccounts.listRecordedPaneLanes + // Why the shape check: a preload older than this handler has no such method, + // and reaching that case must read as "no records", not as a scan failure. + if (typeof listRecordedPaneLanes !== 'function') { + return {} + } + return await listRecordedPaneLanes({ ptyIds: localPtyIds }).catch(() => ({})) +} + +/** + * Re-checks a shell reading with a fresh, uncached scan before trusting it. + * + * Why: the cached foreground read can flap to the pane's shell for a live Codex + * session (#11064), and a spurious shell here silently skips the restart card — + * no error, no retry. Mirrors main's terminalHasShellForegroundProcess, which + * already refuses to conclude "agent done" from an unconfirmed shell reading. + * Gated to Orca-launched Codex panes so an account switch never spends the + * expensive fresh scan on ordinary shell terminals, and only an affirmative + * codex answer flips the decision: a confirmed shell (user exited Codex) and a + * null (scan failed or unsupported) both keep today's ineligible outcome. + */ +async function isConfirmedCodexForegroundDespiteShellReading( + state: AppState, + ptyId: string, + launchAgent: TuiAgent | undefined, + inspection: RuntimeTerminalProcessInspection +): Promise { + if ( + launchAgent !== 'codex' || + inspection.unavailable === true || + inspection.foregroundProcess === null || + !isShellProcess(inspection.foregroundProcess) + ) { + return false + } + const confirmed = await confirmRuntimeTerminalForegroundProcess(state.settings, ptyId) + return isCodexForegroundProcess(confirmed) +} + +/** + * Reports which panes are running Codex, skipping any outside the caller's lane. + * + * Why the lane filter runs BEFORE inspection rather than after: an out-of-lane + * answer cannot change the outcome, and the inspection is not free — a pane on a + * relay environment costs a 15s-timeout RPC per look. Skipping first is also + * what keeps a restart notice (which drops every keystroke in the pane) off a + * remote Codex session that no local account change can possibly strand. + */ async function scanCodexPanes( state: AppState, - ptyIdFilter: ReadonlySet | null + args: { + ptyIdFilter: ReadonlySet | null + isLaneInScope: (laneKey: string) => boolean + } ): Promise { - const tabs = Object.values(state.tabsByWorktree).flat() - const scans = await Promise.all( - tabs.map(async (tab) => { - const ptyIds = (state.ptyIdsByTabId[tab.id] ?? []).filter( - (ptyId) => ptyIdFilter === null || ptyIdFilter.has(ptyId) + const panes = Object.values(state.tabsByWorktree) + .flat() + .flatMap((tab) => + (state.ptyIdsByTabId[tab.id] ?? []) + .filter((ptyId) => args.ptyIdFilter === null || args.ptyIdFilter.has(ptyId)) + .map((ptyId) => ({ tab, ptyId })) + ) + const recordedLanes = await readRecordedCodexPaneLanes(panes.map((pane) => pane.ptyId)) + + // Why: Codex sessions are not reliably discoverable from tab labels. Tabs keep + // fallback names until a CLI emits an OSC title, and Codex does not always do + // that. The live process tree plus the tab's recorded launchAgent are the + // stable evidence that this pane is running Codex. + return Promise.all( + panes.map(async ({ tab, ptyId }) => { + const lane = resolveCodexPaneSelectionLane({ + state, + tab, + ptyId, + recordedLaneKey: recordedLanes[ptyId] + }) + if (!args.isLaneInScope(lane.laneKey)) { + // Why not inconclusive: a pane's lane is fixed at spawn, so this is a + // final answer and the sweep must not spend a retry rung re-asking. + return { + ptyId, + eligible: false, + inconclusive: false, + launchedCodex: false, + notified: false, + laneKey: lane.laneKey, + laneSource: lane.source + } + } + const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then( + (result) => result, + // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. + () => null ) - // Why: Codex sessions are not reliably discoverable from tab labels. - // Tabs keep fallback names until a CLI emits an OSC title, and Codex - // does not always do that. The live process tree plus the tab's recorded - // launchAgent are the stable evidence that this pane is running Codex. - return Promise.all( - ptyIds.map(async (ptyId) => { - const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then( - (result) => result, - // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. - () => null - ) - return { + const eligible = + inspection !== null && + (isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }) || + (await isConfirmedCodexForegroundDespiteShellReading( + state, ptyId, - eligible: - inspection !== null && - isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }), - inconclusive: inspection === null || inspection.unavailable === true, - launchedCodex: tab.launchAgent === 'codex', - notified: false - } - }) - ) + tab.launchAgent, + inspection + ))) + return { + ptyId, + eligible, + inconclusive: inspection === null || inspection.unavailable === true, + launchedCodex: tab.launchAgent === 'codex', + notified: false, + laneKey: lane.laneKey, + laneSource: lane.source + } }) ) - - return scans.flat() } +/** + * Prompts the panes a just-applied account change stranded. + * + * `target` names the selection slot the change wrote. Panes outside that lane — + * a WSL pane on a host switch, or anything running on a relay/SSH machine — + * never had this account injected, so a notice there is pure damage: it mutes a + * terminal that is working correctly. + */ export async function markLiveCodexSessionsForRestart(args: { previousAccountLabel: string nextAccountLabel: string + /** Ids behind the labels. Two accounts can share a label, so without these the + * store cannot tell a switch back to the launch account from a switch to a + * different account that merely reads the same. */ + previousAccountId?: string | null + nextAccountId?: string | null + target?: CodexAccountSelectionTarget | null + /** Set when the change cleared the selection rather than pointing it somewhere. */ + clearsEveryWslDistro?: boolean }): Promise { const state = useAppStore.getState() - const scans = await scanCodexPanes(state, null) + const scans = await scanCodexPanes(state, { + ptyIdFilter: null, + isLaneInScope: getCodexAccountSwitchLaneMatcher({ + settings: state.settings, + target: args.target, + clearsEveryWslDistro: args.clearsEveryWslDistro + }) + }) const liveCodexSessionPtyIds = scans.filter((scan) => scan.eligible).map((scan) => scan.ptyId) if (liveCodexSessionPtyIds.length === 0) { return @@ -76,7 +213,11 @@ export async function markLiveCodexSessionsForRestart(args: { liveCodexSessionPtyIds.map((ptyId) => ({ ptyId, previousAccountLabel: args.previousAccountLabel, - nextAccountLabel: args.nextAccountLabel + nextAccountLabel: args.nextAccountLabel, + ...(args.previousAccountId === undefined + ? {} + : { previousAccountId: args.previousAccountId }), + ...(args.nextAccountId === undefined ? {} : { nextAccountId: args.nextAccountId }) })) ) } @@ -91,12 +232,20 @@ export async function markLiveCodexSessionsForRestart(args: { * * Returns one result per inspected pane so the bind-driven sweep can tell an * answered pane from one whose PTY has not reported a usable process yet. + * + * Scoped to the local host/WSL lanes because the pane-account registry only + * records daemon host spawns: a relay or SSH pane can never be listed stale, so + * inspecting one is a guaranteed-fruitless RPC. listStalePanes then does the + * host-vs-WSL check itself, against each pane's own recorded lane. */ export async function markRestoredStaleCodexSessionsForRestart(args?: { ptyIds?: readonly string[] }): Promise { const state = useAppStore.getState() - const scans = await scanCodexPanes(state, args?.ptyIds ? new Set(args.ptyIds) : null) + const scans = await scanCodexPanes(state, { + ptyIdFilter: args?.ptyIds ? new Set(args.ptyIds) : null, + isLaneInScope: isLocalCodexSelectionLaneKey + }) const liveCodexSessionPtyIds = scans.filter((scan) => scan.eligible).map((scan) => scan.ptyId) if (liveCodexSessionPtyIds.length === 0) { return scans @@ -109,28 +258,53 @@ export async function markRestoredStaleCodexSessionsForRestart(args?: { } const resolveAccountLabel = await createCodexAccountLabelResolver() - useAppStore.getState().markCodexRestartNotices( + const noticedPtyIds = useAppStore.getState().markCodexRestartNotices( stalePanes.map((pane) => ({ ptyId: pane.ptyId, previousAccountLabel: resolveAccountLabel(pane.launchAccountId), - nextAccountLabel: resolveAccountLabel(pane.activeAccountId) + nextAccountLabel: resolveAccountLabel(pane.activeAccountId), + // Why the ids: main decided staleness by id, and the labels can collide. + // Passing only labels hands the store a question it cannot answer. + previousAccountId: pane.launchAccountId, + nextAccountId: pane.activeAccountId })) ) - const notifiedPtyIds = new Set(stalePanes.map((pane) => pane.ptyId)) + // Why not every stale pane: the bind sweep suppresses a "notified" pane for the + // rest of the session, so a pane whose notice the store dropped must not claim + // one — that trades a missing prompt for a permanently missing prompt. + const notifiedPtyIds = new Set(noticedPtyIds) return scans.map((scan) => (notifiedPtyIds.has(scan.ptyId) ? { ...scan, notified: true } : scan)) } +/** + * Names an account for the restart prompt. + * + * Why the collision check: one OpenAI login added under two ChatGPT workspaces + * gives both accounts the same email, and "switch from x@y to x@y" names + * neither. The workspace is appended only when it is what tells them apart. + */ +export function resolveCodexRestartPromptAccountLabel( + accounts: readonly { id: string; email: string; workspaceLabel?: string | null }[], + accountId: string | null | undefined +): string { + if (accountId == null) { + return translate('auto.lib.codex.session.restart.4bd4a3a9c7', 'System default') + } + const account = accounts.find((entry) => entry.id === accountId) + if (!account) { + return translate('auto.lib.codex.session.restart.9f0b1c2d3e', 'Codex account') + } + const sharesEmail = accounts.some( + (entry) => entry.id !== account.id && entry.email === account.email + ) + return sharesEmail && account.workspaceLabel + ? `${account.email} (${account.workspaceLabel})` + : account.email +} + async function createCodexAccountLabelResolver(): Promise<(accountId: string | null) => string> { // Why: a failed roster read still yields usable prompts — the account ids are // already known, only their friendly emails are missing. const accounts = await window.api.codexAccounts.list().catch(() => null) - return (accountId) => { - if (accountId == null) { - return translate('auto.lib.codex.session.restart.4bd4a3a9c7', 'System default') - } - return ( - accounts?.accounts.find((account) => account.id === accountId)?.email ?? - translate('auto.lib.codex.session.restart.9f0b1c2d3e', 'Codex account') - ) - } + return (accountId) => resolveCodexRestartPromptAccountLabel(accounts?.accounts ?? [], accountId) } diff --git a/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts b/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts new file mode 100644 index 00000000000..aeeb1c674c9 --- /dev/null +++ b/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { markRestoredStaleCodexSessionsForRestart } from './codex-session-restart' + +// Why one shared email: doAddAccount has no duplicate-email check, so one OpenAI +// login used in two ChatGPT workspaces produces two accounts with equal labels. +const SHARED_EMAIL = 'shared@example.com' +const FALLBACK_LABEL = 'Codex account' + +function seedRoster( + accounts: { id: string; email: string; workspaceLabel?: string | null }[] +): void { + vi.mocked(window.api.codexAccounts.list).mockResolvedValue({ + accounts, + activeAccountId: 'account-b' + } as never) +} + +function noticeFor(ptyId: string): unknown { + return useAppStore.getState().codexRestartNoticeByPtyId[ptyId] +} + +describe('stale Codex panes are decided by account id, not label', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + useAppStore.setState({ + settings: null as never, + tabsByWorktree: { + wt1: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt1', + title: 'orca-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {} + }) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + getForegroundProcess: vi.fn(), + hasChildProcesses: vi.fn().mockResolvedValue(false), + inspectProcess: vi + .fn() + .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false }), + confirmForegroundProcess: vi.fn().mockResolvedValue(null) + }, + codexAccounts: { + ...originalWindow?.api?.codexAccounts, + list: vi.fn(), + listStalePanes: vi.fn().mockResolvedValue([]) + } + } + } as unknown as typeof window + seedRoster([ + { id: 'account-a', email: SHARED_EMAIL }, + { id: 'account-b', email: SHARED_EMAIL } + ]) + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('keeps the prompt when the two accounts resolve to the same label', async () => { + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + const scans = await markRestoredStaleCodexSessionsForRestart() + + expect(noticeFor('pty-1')).toMatchObject({ + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + expect(scans.find((scan) => scan.ptyId === 'pty-1')?.notified).toBe(true) + }) + + it('keeps the prompt when the roster read fails and every label collapses', async () => { + vi.mocked(window.api.codexAccounts.list).mockRejectedValue(new Error('roster unavailable')) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + const scans = await markRestoredStaleCodexSessionsForRestart() + + expect(noticeFor('pty-1')).toMatchObject({ + previousAccountLabel: FALLBACK_LABEL, + nextAccountLabel: FALLBACK_LABEL, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + expect(scans.find((scan) => scan.ptyId === 'pty-1')?.notified).toBe(true) + }) + + it('disambiguates shared emails by ChatGPT workspace', async () => { + seedRoster([ + { id: 'account-a', email: SHARED_EMAIL, workspaceLabel: 'Personal' }, + { id: 'account-b', email: SHARED_EMAIL, workspaceLabel: 'Acme' } + ]) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + await markRestoredStaleCodexSessionsForRestart() + + expect(noticeFor('pty-1')).toMatchObject({ + previousAccountLabel: `${SHARED_EMAIL} (Personal)`, + nextAccountLabel: `${SHARED_EMAIL} (Acme)` + }) + }) + + it('raises no notice and reports nothing notified for a pane on the selected account', async () => { + const scans = await markRestoredStaleCodexSessionsForRestart() + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(scans.every((scan) => scan.notified === false)).toBe(true) + }) + + it('does not report notified for a pane whose notice the store dropped', async () => { + // The pane already carries a notice remembering account-b as its launch + // account, so re-marking it against active account-b collapses the notice. + useAppStore.setState({ + codexRestartNoticeByPtyId: { + 'pty-1': { + previousAccountLabel: SHARED_EMAIL, + nextAccountLabel: SHARED_EMAIL, + previousAccountId: 'account-b', + nextAccountId: 'account-a' + } + } + }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + const scans = await markRestoredStaleCodexSessionsForRestart() + + expect(noticeFor('pty-1')).toBeUndefined() + // Why this matters: the sweep permanently suppresses every pane it is told + // was notified, so a dropped notice must never claim one. + expect(scans.find((scan) => scan.ptyId === 'pty-1')?.notified).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/codex-stale-pane-sweep.test.ts b/src/renderer/src/lib/codex-stale-pane-sweep.test.ts index 5448315e3ac..390021b6bd1 100644 --- a/src/renderer/src/lib/codex-stale-pane-sweep.test.ts +++ b/src/renderer/src/lib/codex-stale-pane-sweep.test.ts @@ -91,7 +91,9 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) @@ -136,7 +138,9 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) @@ -176,7 +180,9 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) expect(inspectCallCountFor('pty-1')).toBe(4) }) @@ -230,7 +236,9 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) @@ -266,7 +274,9 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) @@ -288,6 +298,34 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledTimes(1) }) + it('does not suppress a pane whose notice the store dropped', async () => { + // Why: suppression is permanent for the session, so claiming a pane was + // notified when no notice survived silently strands it on the old account. + // The pane already remembers account-b as its launch account, which + // collapses a fresh notice pointing back at account-b. + useAppStore.setState({ + codexRestartNoticeByPtyId: { + 'pty-1': { + previousAccountLabel: ACCOUNT_B, + nextAccountLabel: ACCOUNT_A, + previousAccountId: 'account-b', + nextAccountId: 'account-a' + } + } + }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE]) + + notifyCodexPaneBoundForStaleSweep('pty-1') + await vi.advanceTimersByTimeAsync(300) + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() + expect(inspectCallCountFor('pty-1')).toBe(1) + + notifyCodexPaneBoundForStaleSweep('pty-1') + await vi.advanceTimersByTimeAsync(300) + + expect(inspectCallCountFor('pty-1')).toBe(2) + }) + it('never marks a plain shell pane, so its input is never blocked', async () => { useAppStore.setState({ tabsByWorktree: { @@ -333,7 +371,9 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) @@ -350,7 +390,57 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + }) + + // Why: recordCodexPaneAccountForSpawn bails on anything that is not a daemon + // HOST spawn, so no remote/SSH pane is ever in the registry and listStalePanes + // can never report one. Every rung one takes is a 15s-timeout RPC spent to + // learn nothing, five times over. + it.each(['remote:env-1@@term-1', 'remote:term-1', 'ssh:my-box@@pty-7'])( + 'never queues %s, so no rung spends an RPC on it', + async (ptyId) => { + useAppStore.setState({ ptyIdsByTabId: { 'tab-1': [ptyId] } }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId, launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + notifyCodexPaneBoundForStaleSweep(ptyId) + // Why assert the timer before advancing it: the scan would skip this pane + // anyway, so only an unarmed queue proves it was rejected at the door + // rather than costing a flush + scan on every rung. + expect(vi.getTimerCount()).toBe(0) + + // Well past the whole ladder, so this pins "never queued", not "not yet". + await vi.advanceTimersByTimeAsync(120_000) + + expect(vi.getTimerCount()).toBe(0) + expect(window.api.codexAccounts.listStalePanes).not.toHaveBeenCalled() + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + } + ) + + it('still sweeps the local panes bound alongside a remote one', async () => { + useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'remote:env-1@@term-1'] } }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE]) + + notifyCodexPaneBoundForStaleSweep('remote:env-1@@term-1') + notifyCodexPaneBoundForStaleSweep('pty-1') + await vi.advanceTimersByTimeAsync(300) + + expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledExactlyOnceWith({ + ptyIds: ['pty-1'] + }) + expect(inspectCallCountFor('remote:env-1@@term-1')).toBe(0) + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' }) }) }) diff --git a/src/renderer/src/lib/codex-stale-pane-sweep.ts b/src/renderer/src/lib/codex-stale-pane-sweep.ts index 178334643ab..c75cea5fa95 100644 --- a/src/renderer/src/lib/codex-stale-pane-sweep.ts +++ b/src/renderer/src/lib/codex-stale-pane-sweep.ts @@ -2,6 +2,7 @@ import { markRestoredStaleCodexSessionsForRestart, type CodexPaneScanResult } from './codex-session-restart' +import { isForeignMachineCodexPtyId } from './codex-pane-selection-lane' // Why: the first delay coalesces the startup burst of binds and lets // updateTabPtyId (written just after the layout binding) land, since the scan @@ -36,6 +37,12 @@ export function notifyCodexPaneBoundForStaleSweep(ptyId: string): void { if (notifiedPtyIds.has(ptyId)) { return } + // Why: the pane-account registry only records daemon HOST spawns, so a relay + // or SSH pane can never come back stale — every rung it takes is a remote RPC + // (15s timeout) spent to learn nothing. Drop it before it reaches the queue. + if (isForeignMachineCodexPtyId(ptyId)) { + return + } queue(ptyId, SWEEP_ATTEMPT_DELAYS_MS[0]) armForEarliestDue() } diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts new file mode 100644 index 00000000000..49dcf0e80cb --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts @@ -0,0 +1,8 @@ +import type { EventProps } from '../../../shared/telemetry-events' + +export type DirectSshReconnectProductProps = EventProps<'direct_ssh_reconnect_operation'> + +export type DirectSshReconnectProductSink = ( + name: 'direct_ssh_reconnect_operation', + props: DirectSshReconnectProductProps +) => void diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts new file mode 100644 index 00000000000..6df19c7a78c --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' +import { + createDirectSshReconnectProductTelemetryAdapter, + toDirectSshReconnectProductProps +} from './direct-ssh-reconnect-product-telemetry' + +function coordinatorEvent( + overrides: Partial = {} +): DirectSshCoordinatorTelemetry { + return { + mode: 'prepare-only', + reason: 'workspace-snapshot', + outcome: 'degraded', + durationMs: 900, + staleBindingsCleared: 1, + retriedTerminals: 2, + correctedTerminals: 3, + terminalFinalizationDurationMs: 4, + catalogOutcome: 'complete', + catalogDurationMs: 20, + gitWorktreeCount: 5, + folderWorkspaceCount: 2, + ambiguousOwnerCount: 1, + contradictoryOwnerCount: 1, + repoOutcomes: { + complete: 2, + 'non-authoritative': 1, + 'timed-out': 1, + 'cancel-budget-exhausted': 1, + canceled: 1, + stale: 1, + rejected: 1 + }, + lineageOutcome: 'degraded', + queueWaitDurationsMs: [30, 10, 20, 40], + providerExecutionDurationsMs: [400, 100, 300, 200], + timeoutRetryCount: 2, + locallySettledWaiterCount: 4, + cancelDebtCount: 2, + replacementAdmissionDelayedCount: 1, + overlappingJoinCount: 3, + peakLocallyUnsettled: 4, + estimatedLateWorkAllowanceCount: 2, + authorityRotationCount: 1, + damped: true, + ...overrides + } +} + +describe('direct SSH reconnect product telemetry', () => { + it('maps one coordinator aggregate to privacy-safe product fields', () => { + const props = toDirectSshReconnectProductProps(coordinatorEvent()) + + expect(props).toMatchObject({ + mode: 'prepare_only', + reason: 'workspace_snapshot', + outcome: 'degraded', + repo_retrying_count: 2, + repo_timed_out_count: 1, + repo_rejected_count: 1, + repo_canceled_count: 1, + repo_stale_count: 1, + lineage_degraded_count: 1, + queue_wait_sample_count: 4, + queue_wait_duration_ms_p50: 20, + queue_wait_duration_ms_p95: 40, + provider_execution_sample_count: 4, + provider_execution_duration_ms_p50: 200, + provider_execution_duration_ms_p99: 400, + cancel_debt_count: 2, + replacement_admission_delayed_count: 1, + overlapping_join_count: 3, + timeout_retry_count: 2, + damped_preparation_count: 1 + }) + expect(Object.keys(props).join(' ')).not.toMatch( + /target_id|repo_id|host|path|label|user|request_id|lease_id|terminal_id|error/ + ) + expect(props).not.toHaveProperty('terminal_correction_failed_count') + expect(props).not.toHaveProperty('terminal_correction_rearmed_count') + expect(props).not.toHaveProperty('concurrent_non_coordinator_call_count') + expect(props).not.toHaveProperty('arrival_order_reply_discarded_count') + }) + + it('emits exactly one typed event per adapter call', () => { + const sink = vi.fn() + const emit = createDirectSshReconnectProductTelemetryAdapter(sink) + + emit(coordinatorEvent()) + + expect(sink).toHaveBeenCalledOnce() + expect(sink).toHaveBeenCalledWith( + 'direct_ssh_reconnect_operation', + expect.objectContaining({ total_duration_ms: 900 }) + ) + }) + + it('does not let telemetry failures escape into recovery', () => { + const emit = createDirectSshReconnectProductTelemetryAdapter(() => { + throw new Error('telemetry unavailable') + }) + + expect(() => emit(coordinatorEvent())).not.toThrow() + }) +}) diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts new file mode 100644 index 00000000000..bd798ad4e06 --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts @@ -0,0 +1,127 @@ +import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' +import { track } from './telemetry' +import type { + DirectSshReconnectProductProps, + DirectSshReconnectProductSink +} from './direct-ssh-reconnect-product-telemetry-types' + +const MAX_COUNT = 1_000_000 +const MAX_DURATION_MS = 86_400_000 + +function boundedInteger(value: number, maximum = MAX_COUNT): number { + return Math.min(maximum, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))) +} + +function durationDistribution(values: readonly number[]): { + count: number + p50: number + p95: number + p99: number + max: number +} { + const sorted = values + .map((value) => boundedInteger(value, MAX_DURATION_MS)) + .toSorted((a, b) => a - b) + const percentile = (quantile: number): number => + sorted.length === 0 ? 0 : sorted[Math.max(0, Math.ceil(sorted.length * quantile) - 1)] + return { + count: boundedInteger(sorted.length), + p50: percentile(0.5), + p95: percentile(0.95), + p99: percentile(0.99), + max: sorted.at(-1) ?? 0 + } +} + +function lineageCounts( + outcome: DirectSshCoordinatorTelemetry['lineageOutcome'] +): Pick< + DirectSshReconnectProductProps, + | 'lineage_complete_count' + | 'lineage_degraded_count' + | 'lineage_canceled_count' + | 'lineage_stale_count' + | 'lineage_not_started_count' +> { + return { + lineage_complete_count: outcome === 'complete' ? 1 : 0, + lineage_degraded_count: outcome === 'degraded' ? 1 : 0, + lineage_canceled_count: outcome === 'canceled' ? 1 : 0, + lineage_stale_count: outcome === 'stale' ? 1 : 0, + lineage_not_started_count: outcome === 'not-started' ? 1 : 0 + } +} + +export function toDirectSshReconnectProductProps( + event: DirectSshCoordinatorTelemetry +): DirectSshReconnectProductProps { + const queueWait = durationDistribution(event.queueWaitDurationsMs) + const providerExecution = durationDistribution(event.providerExecutionDurationsMs) + return { + mode: event.mode === 'prepare-only' ? 'prepare_only' : 'reconnect', + reason: event.reason.replaceAll('-', '_') as DirectSshReconnectProductProps['reason'], + outcome: event.outcome, + terminal_retried_count: boundedInteger(event.retriedTerminals), + terminal_stale_binding_cleared_count: boundedInteger(event.staleBindingsCleared), + terminal_correction_succeeded_count: boundedInteger(event.correctedTerminals), + catalog_complete_count: event.catalogOutcome === 'complete' ? 1 : 0, + catalog_degraded_count: event.catalogOutcome === 'degraded' ? 1 : 0, + catalog_stale_count: event.catalogOutcome === 'stale' ? 1 : 0, + repo_complete_count: boundedInteger(event.repoOutcomes.complete), + repo_non_authoritative_count: boundedInteger(event.repoOutcomes['non-authoritative']), + // One first-timeout transition per repo makes retrying repos equal timeout retries. + repo_retrying_count: boundedInteger(event.timeoutRetryCount), + repo_timed_out_count: boundedInteger(event.repoOutcomes['timed-out']), + repo_cancel_budget_exhausted_count: boundedInteger( + event.repoOutcomes['cancel-budget-exhausted'] + ), + repo_canceled_count: boundedInteger(event.repoOutcomes.canceled), + repo_stale_count: boundedInteger(event.repoOutcomes.stale), + repo_rejected_count: boundedInteger(event.repoOutcomes.rejected), + ...lineageCounts(event.lineageOutcome), + git_worktree_count: boundedInteger(event.gitWorktreeCount), + folder_workspace_count: boundedInteger(event.folderWorkspaceCount), + ambiguous_owner_count: boundedInteger(event.ambiguousOwnerCount), + contradictory_owner_count: boundedInteger(event.contradictoryOwnerCount), + total_duration_ms: boundedInteger(event.durationMs, MAX_DURATION_MS), + terminal_finalization_duration_ms: boundedInteger( + event.terminalFinalizationDurationMs, + MAX_DURATION_MS + ), + catalog_duration_ms: boundedInteger(event.catalogDurationMs, MAX_DURATION_MS), + queue_wait_sample_count: queueWait.count, + queue_wait_duration_ms_p50: queueWait.p50, + queue_wait_duration_ms_p95: queueWait.p95, + queue_wait_duration_ms_p99: queueWait.p99, + queue_wait_duration_ms_max: queueWait.max, + provider_execution_sample_count: providerExecution.count, + provider_execution_duration_ms_p50: providerExecution.p50, + provider_execution_duration_ms_p95: providerExecution.p95, + provider_execution_duration_ms_p99: providerExecution.p99, + provider_execution_duration_ms_max: providerExecution.max, + timeout_retry_count: boundedInteger(event.timeoutRetryCount), + locally_settled_waiter_count: boundedInteger(event.locallySettledWaiterCount), + cancel_debt_count: boundedInteger(event.cancelDebtCount), + replacement_admission_delayed_count: boundedInteger(event.replacementAdmissionDelayedCount), + overlapping_join_count: boundedInteger(event.overlappingJoinCount), + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: boundedInteger( + event.peakLocallyUnsettled, + 5 + ), + estimated_late_work_allowance_count: boundedInteger(event.estimatedLateWorkAllowanceCount, 2), + authority_rotation_count: boundedInteger(event.authorityRotationCount), + damped_preparation_count: event.damped ? 1 : 0 + } +} + +export function createDirectSshReconnectProductTelemetryAdapter( + sink: DirectSshReconnectProductSink = track +): (event: DirectSshCoordinatorTelemetry) => void { + return (event) => { + try { + sink('direct_ssh_reconnect_operation', toDirectSshReconnectProductProps(event)) + } catch { + // Recovery cannot depend on product telemetry. + } + } +} diff --git a/src/renderer/src/lib/direct-ssh-target-owner-index.ts b/src/renderer/src/lib/direct-ssh-target-owner-index.ts new file mode 100644 index 00000000000..84a91aeeaca --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-owner-index.ts @@ -0,0 +1,11 @@ +export function indexDirectSshOwnerRows( + rows: readonly T[] +): Map { + const rowsById = new Map() + for (const row of rows) { + const owners = rowsById.get(row.id) ?? [] + owners.push(row) + rowsById.set(row.id, owners) + } + return rowsById +} diff --git a/src/renderer/src/lib/direct-ssh-target-scope-types.ts b/src/renderer/src/lib/direct-ssh-target-scope-types.ts new file mode 100644 index 00000000000..e063a64c42c --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-scope-types.ts @@ -0,0 +1,52 @@ +import type { + FolderWorkspace, + ProjectGroup, + Repo, + WorkspaceKey, + Worktree +} from '../../../shared/types' + +export type DirectSshGitRepoRef = { + repoId: string + executionHostId: `ssh:${string}` +} + +export type DirectSshTargetScope = { + catalogRevision: number + gitRepos: DirectSshGitRepoRef[] + gitWorktreeIds: Set + terminalWorkspaceKeys: Set + lineageWorkspaceKeys: Set + ambiguousOwnerCount: number + contradictoryOwnerCount: number +} + +export type DirectSshRepoOwner = Pick< + Repo, + 'id' | 'path' | 'projectGroupId' | 'connectionId' | 'executionHostId' +> +export type DirectSshWorktreeOwner = Pick< + Worktree, + 'id' | 'repoId' | 'hostId' | 'runtimeOwnerEnvironmentId' +> +export type DirectSshFolderOwner = Pick< + FolderWorkspace, + 'id' | 'projectGroupId' | 'folderPath' | 'connectionId' +> +export type DirectSshGroupOwner = Pick< + ProjectGroup, + 'id' | 'parentGroupId' | 'connectionId' | 'executionHostId' +> + +export type DirectSshTargetScopeInput = { + targetId: string + catalogRevision: number + repos: readonly DirectSshRepoOwner[] + worktreesByRepo?: Readonly> + detectedWorktreesByRepo?: Readonly< + Record + > + folderWorkspaces?: readonly DirectSshFolderOwner[] + projectGroups?: readonly DirectSshGroupOwner[] + restoredRuntimeHostIdByWorkspaceSessionKey?: Readonly> +} diff --git a/src/renderer/src/lib/direct-ssh-target-scope.test.ts b/src/renderer/src/lib/direct-ssh-target-scope.test.ts new file mode 100644 index 00000000000..a010fab8984 --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-scope.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + resolveDirectSshTargetScope, + type DirectSshTargetScopeInput +} from './direct-ssh-target-scope' + +const baseInput: DirectSshTargetScopeInput = { + targetId: 'target-a', + catalogRevision: 17, + repos: [] +} + +describe('resolveDirectSshTargetScope', () => { + it('resolves duplicate repo IDs by exact SSH host instead of first wins', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'shared', + path: '/local/shared', + projectGroupId: null, + connectionId: null, + executionHostId: 'local' + }, + { + id: 'shared', + path: '/remote/shared', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + shared: [ + { + id: 'shared::remote', + repoId: 'shared', + hostId: 'ssh:target-a' + } + ] + } + }) + + expect(scope.catalogRevision).toBe(17) + expect(scope.gitRepos).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) + expect(scope.gitWorktreeIds).toEqual(new Set(['shared::remote'])) + expect(scope.terminalWorkspaceKeys).toEqual(new Set(['shared::remote'])) + expect(scope.lineageWorkspaceKeys).toEqual(new Set([worktreeWorkspaceKey('shared::remote')])) + expect(scope.ambiguousOwnerCount).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(0) + }) + + it('keeps duplicate same-host repo owners ambiguous', () => { + const duplicate = { + id: 'duplicate', + path: '/remote/duplicate', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' as const + } + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [duplicate, { ...duplicate }], + worktreesByRepo: { + duplicate: [ + { + id: 'duplicate::worktree', + repoId: 'duplicate', + hostId: 'ssh:target-a' + } + ] + } + }) + + expect(scope.gitRepos).toEqual([]) + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.ambiguousOwnerCount).toBe(2) + }) + + it('gives folder workspaces the same exact target isolation as Git worktrees', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'remote-repo', + path: '/srv/project/repo', + projectGroupId: 'remote-group', + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + 'remote-repo': [ + { + id: 'remote-repo::worktree', + repoId: 'remote-repo', + hostId: 'ssh:target-a' + } + ] + }, + projectGroups: [ + { + id: 'remote-group', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'remote-folder', + projectGroupId: 'remote-group', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ] + }) + + expect(scope.gitWorktreeIds).toEqual(new Set(['remote-repo::worktree'])) + expect(scope.terminalWorkspaceKeys).toEqual( + new Set(['remote-repo::worktree', 'folder:remote-folder']) + ) + expect(scope.lineageWorkspaceKeys).toEqual( + new Set([worktreeWorkspaceKey('remote-repo::worktree'), folderWorkspaceKey('remote-folder')]) + ) + }) + + it('keeps runtime-owned SSH work isolated from the direct target', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'runtime-ssh', + path: '/runtime/repo', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + 'runtime-ssh': [ + { + id: 'runtime-ssh::worktree', + repoId: 'runtime-ssh', + hostId: 'ssh:target-a', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + }) + + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(1) + }) + + it('rejects explicit worktree and exact repo ownership contradictions', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'repo', + path: '/target-a/repo', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + repo: [{ id: 'repo::wrong-host', repoId: 'repo', hostId: 'ssh:target-b' }] + } + }) + + expect(scope.gitRepos).toEqual([{ repoId: 'repo', executionHostId: 'ssh:target-a' }]) + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(1) + }) + + it('excludes ambiguous legacy and duplicate same-host folder owners', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [], + worktreesByRepo: { + legacy: [{ id: 'legacy::worktree', repoId: 'legacy' }], + unresolved: [ + { + id: 'unresolved::worktree', + repoId: 'unresolved', + hostId: 'runtime:unresolved-owner' + } + ] + }, + projectGroups: [ + { + id: 'group', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'duplicate-folder', + projectGroupId: 'group', + folderPath: '/srv/project', + connectionId: 'target-a' + }, + { + id: 'duplicate-folder', + projectGroupId: 'group', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ] + }) + + expect(scope.gitWorktreeIds.size).toBe(0) + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.ambiguousOwnerCount).toBe(3) + expect(scope.contradictoryOwnerCount).toBe(0) + }) + + it('rejects conflicting folder workspace, group, repo, and restored provenance', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [ + { + id: 'repo-b', + path: '/srv/project/repo', + projectGroupId: 'group', + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + projectGroups: [ + { + id: 'group', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'mixed-folder', + projectGroupId: 'group', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ], + restoredRuntimeHostIdByWorkspaceSessionKey: { + 'folder:mixed-folder': 'runtime:hub-a' + } + }) + + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.contradictoryOwnerCount).toBe(1) + }) + + it('does not infer an effective folder connection from an SSH host stamp alone', () => { + const scope = resolveDirectSshTargetScope({ + ...baseInput, + repos: [], + projectGroups: [ + { + id: 'legacy-group', + parentGroupId: null, + connectionId: null, + executionHostId: 'ssh:target-a' + } + ], + folderWorkspaces: [ + { + id: 'legacy-folder', + projectGroupId: 'legacy-group', + folderPath: '/srv/legacy', + connectionId: null + } + ] + }) + + expect(scope.terminalWorkspaceKeys.size).toBe(0) + expect(scope.ambiguousOwnerCount).toBe(1) + }) +}) diff --git a/src/renderer/src/lib/direct-ssh-target-scope.ts b/src/renderer/src/lib/direct-ssh-target-scope.ts new file mode 100644 index 00000000000..4ffa6da3bd4 --- /dev/null +++ b/src/renderer/src/lib/direct-ssh-target-scope.ts @@ -0,0 +1,310 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../../shared/project-groups' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { + DirectSshFolderOwner as FolderOwner, + DirectSshGitRepoRef, + DirectSshGroupOwner as GroupOwner, + DirectSshRepoOwner as RepoOwner, + DirectSshTargetScope, + DirectSshTargetScopeInput, + DirectSshWorktreeOwner as WorktreeOwner +} from './direct-ssh-target-scope-types' +import { indexDirectSshOwnerRows } from './direct-ssh-target-owner-index' +export type { + DirectSshGitRepoRef, + DirectSshTargetScope, + DirectSshTargetScopeInput +} from './direct-ssh-target-scope-types' + +type HostEvidence = { + hosts: Set + ambiguous: boolean + contradictory: boolean +} + +function newHostEvidence(): HostEvidence { + return { hosts: new Set(), ambiguous: false, contradictory: false } +} + +function addHostEvidence(evidence: HostEvidence, rawHostId: string | null | undefined): void { + if (!rawHostId?.trim()) { + return + } + const host = parseExecutionHostId(rawHostId) + if (!host) { + evidence.ambiguous = true + return + } + if (host.kind === 'runtime' && host.environmentId === 'unresolved-owner') { + evidence.ambiguous = true + return + } + evidence.hosts.add(host.id) +} + +function resolveRepoEvidence(repo: RepoOwner): HostEvidence { + const evidence = newHostEvidence() + addHostEvidence(evidence, repo.executionHostId) + if (repo.connectionId?.trim()) { + evidence.hosts.add(toSshExecutionHostId(repo.connectionId.trim())) + } + evidence.hosts = + evidence.hosts.size === 0 && !evidence.ambiguous + ? new Set([getRepoExecutionHostId(repo)]) + : evidence.hosts + evidence.contradictory = evidence.hosts.size > 1 + return evidence +} + +function collectWorktreeRows(input: DirectSshTargetScopeInput): Map { + return indexDirectSshOwnerRows([ + ...Object.values(input.worktreesByRepo ?? {}).flat(), + ...Object.values(input.detectedWorktreesByRepo ?? {}).flatMap((result) => result.worktrees) + ]) +} + +function addRepoDerivedEvidence( + evidence: HostEvidence, + repoRows: readonly RepoOwner[], + explicitHosts: ReadonlySet +): void { + const repoHosts = new Set() + const repoHostCounts = new Map() + let hasInvalidRepo = false + for (const repo of repoRows) { + const repoEvidence = resolveRepoEvidence(repo) + hasInvalidRepo ||= repoEvidence.ambiguous + evidence.contradictory ||= repoEvidence.contradictory + for (const host of repoEvidence.hosts) { + repoHosts.add(host) + repoHostCounts.set(host, (repoHostCounts.get(host) ?? 0) + 1) + } + } + if (explicitHosts.size > 0) { + const exactHosts = [...explicitHosts].filter((host) => repoHosts.has(host)) + if (exactHosts.length > 0) { + for (const host of exactHosts) { + evidence.hosts.add(host) + evidence.ambiguous ||= (repoHostCounts.get(host) ?? 0) > 1 + } + } else if (repoHosts.size === 1) { + evidence.hosts.add([...repoHosts][0]) + } else if (repoHosts.size > 1) { + evidence.ambiguous = true + } + } else if (repoHosts.size === 1) { + const repoHost = [...repoHosts][0] + evidence.hosts.add(repoHost) + evidence.ambiguous ||= (repoHostCounts.get(repoHost) ?? 0) > 1 + } else if (repoHosts.size > 1) { + evidence.ambiguous = true + } + evidence.ambiguous ||= hasInvalidRepo +} + +function resolveWorktreeEvidence( + input: DirectSshTargetScopeInput, + rows: readonly WorktreeOwner[], + repoRowsById: ReadonlyMap +): HostEvidence { + const evidence = newHostEvidence() + const repoIds = new Set(rows.map((row) => row.repoId)) + evidence.ambiguous ||= repoIds.size !== 1 + const explicitHosts = new Set() + for (const row of rows) { + const parsedHost = parseExecutionHostId(row.hostId) + if (row.hostId?.trim() && !parsedHost) { + evidence.ambiguous = true + } else if (parsedHost?.kind === 'runtime' && parsedHost.environmentId === 'unresolved-owner') { + evidence.ambiguous = true + } else if (parsedHost) { + explicitHosts.add(parsedHost.id) + evidence.hosts.add(parsedHost.id) + } + const runtimeOwner = row.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwner) { + evidence.hosts.add(toRuntimeExecutionHostId(runtimeOwner)) + } + } + for (const repoId of repoIds) { + const repoRows = repoRowsById.get(repoId) + if (repoRows) { + addRepoDerivedEvidence(evidence, repoRows, explicitHosts) + } else if (explicitHosts.size === 0) { + evidence.ambiguous = true + } + } + const restored = input.restoredRuntimeHostIdByWorkspaceSessionKey + addHostEvidence(evidence, restored?.[rows[0].id]) + addHostEvidence(evidence, restored?.[worktreeWorkspaceKey(rows[0].id)]) + evidence.contradictory ||= evidence.hosts.size > 1 + return evidence +} + +function getFolderCandidateRepos( + folder: FolderOwner, + groups: readonly GroupOwner[], + repos: readonly RepoOwner[], + scopeConnectionId: string | null +): RepoOwner[] { + const groupIds = getProjectGroupSubtreeIds(groups, folder.projectGroupId) + const groupRepos = repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + if (scopeConnectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === scopeConnectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnections = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnections.has(repo.connectionId ?? null)) + ] +} + +function resolveFolderEvidence( + input: DirectSshTargetScopeInput, + folder: FolderOwner, + group: GroupOwner | undefined +): HostEvidence { + const evidence = newHostEvidence() + const folderConnection = folder.connectionId?.trim() || null + const groupConnection = group?.connectionId?.trim() || null + if (folderConnection) { + evidence.hosts.add(toSshExecutionHostId(folderConnection)) + } + if (groupConnection) { + evidence.hosts.add(toSshExecutionHostId(groupConnection)) + } + addHostEvidence(evidence, group?.executionHostId) + addHostEvidence( + evidence, + input.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folder.id)] + ) + + const scopeConnection = folderConnection ?? groupConnection + const candidateRepos = getFolderCandidateRepos( + folder, + input.projectGroups ?? [], + input.repos, + scopeConnection + ) + const repoOwnerKeys = new Set() + for (const repo of candidateRepos) { + const repoEvidence = resolveRepoEvidence(repo) + evidence.ambiguous ||= repoEvidence.ambiguous + evidence.contradictory ||= repoEvidence.contradictory + for (const host of repoEvidence.hosts) { + const ownerKey = JSON.stringify([repo.id, host]) + evidence.ambiguous ||= repoOwnerKeys.has(ownerKey) + repoOwnerKeys.add(ownerKey) + evidence.hosts.add(host) + } + } + const hasSshOwner = [...evidence.hosts].some( + (hostId) => parseExecutionHostId(hostId)?.kind === 'ssh' + ) + const hasConnectionOwner = + Boolean(scopeConnection) || candidateRepos.some((repo) => Boolean(repo.connectionId?.trim())) + evidence.ambiguous ||= !group || (hasSshOwner && !hasConnectionOwner) + evidence.contradictory ||= evidence.hosts.size > 1 + return evidence +} + +export function resolveDirectSshTargetScope( + input: DirectSshTargetScopeInput +): DirectSshTargetScope { + const expectedHost = toSshExecutionHostId(input.targetId) + const repoRowsById = indexDirectSshOwnerRows(input.repos) + const gitRepos: DirectSshGitRepoRef[] = [] + let ambiguousOwnerCount = 0 + let contradictoryOwnerCount = 0 + + for (const [repoId, rows] of repoRowsById) { + const matchingRows = rows.filter((repo) => { + const evidence = resolveRepoEvidence(repo) + if (evidence.contradictory) { + contradictoryOwnerCount++ + return false + } + if (evidence.ambiguous) { + ambiguousOwnerCount++ + return false + } + return evidence.hosts.has(expectedHost) + }) + if (matchingRows.length === 1) { + gitRepos.push({ repoId, executionHostId: expectedHost }) + } else if (matchingRows.length > 1) { + ambiguousOwnerCount++ + } + } + + const gitWorktreeIds = new Set() + const terminalWorkspaceKeys = new Set() + const lineageWorkspaceKeys = new Set>() + for (const [worktreeId, rows] of collectWorktreeRows(input)) { + const evidence = resolveWorktreeEvidence(input, rows, repoRowsById) + if (evidence.contradictory) { + contradictoryOwnerCount++ + } else if (evidence.ambiguous || evidence.hosts.size === 0) { + ambiguousOwnerCount++ + } else if (evidence.hosts.has(expectedHost)) { + gitWorktreeIds.add(worktreeId) + terminalWorkspaceKeys.add(worktreeId) + lineageWorkspaceKeys.add(worktreeWorkspaceKey(worktreeId)) + } + } + + const folderRowsById = indexDirectSshOwnerRows(input.folderWorkspaces ?? []) + const groupRowsById = indexDirectSshOwnerRows(input.projectGroups ?? []) + for (const [folderId, rows] of folderRowsById) { + if (rows.length !== 1) { + ambiguousOwnerCount++ + continue + } + const groupRows = groupRowsById.get(rows[0].projectGroupId) ?? [] + if (groupRows.length > 1) { + ambiguousOwnerCount++ + continue + } + const evidence = resolveFolderEvidence(input, rows[0], groupRows[0]) + if (evidence.contradictory) { + contradictoryOwnerCount++ + } else if (evidence.ambiguous || evidence.hosts.size === 0) { + ambiguousOwnerCount++ + } else if (evidence.hosts.has(expectedHost)) { + const workspaceKey = folderWorkspaceKey(folderId) + terminalWorkspaceKeys.add(workspaceKey) + lineageWorkspaceKeys.add(workspaceKey) + } + } + + return { + catalogRevision: input.catalogRevision, + gitRepos, + gitWorktreeIds, + terminalWorkspaceKeys, + lineageWorkspaceKeys, + ambiguousOwnerCount, + contradictoryOwnerCount + } +} diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index f8cfdebe10d..1e4e0aec2f0 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -3,10 +3,8 @@ import type { ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult } from '../../../shared/types' -import { - getEphemeralVmRecipeResultProjectRoot, - type EphemeralVmRecipeResultWarning -} from '../../../shared/ephemeral-vm-recipes' +import { getEphemeralVmRecipeResultProjectRoot } from '../../../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeResultWarning } from '../../../shared/ephemeral-vm-recipe-diagnostics' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { translate } from '@/i18n/i18n' import { assertRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/lib/floating-workspace-focus-reclaim.ts b/src/renderer/src/lib/floating-workspace-focus-reclaim.ts new file mode 100644 index 00000000000..a36325ab1fa --- /dev/null +++ b/src/renderer/src/lib/floating-workspace-focus-reclaim.ts @@ -0,0 +1,21 @@ +// One-shot keyboard-reclaim intent for the floating workspace panel. +// A sticky boolean, NOT a focus mirror: set at close-initiation while the panel owns keyboard +// ownership *and only for a close that will empty the panel*, captured before the destructive DOM +// removal (which blurs the pane and would flip a live focus mirror to false first). The panel's +// visibleFloatingItemCount→0 effect consumes it to re-grab keyboard ownership for the next +// Cmd/Ctrl+T. Genuine outside releases (outside pointer-down, window blur to another app) clear it. +let floatingPanelReclaimIntent = false + +export function armFloatingPanelReclaimIntent(): void { + floatingPanelReclaimIntent = true +} + +export function consumeFloatingPanelReclaimIntent(): boolean { + const armed = floatingPanelReclaimIntent + floatingPanelReclaimIntent = false + return armed +} + +export function clearFloatingPanelReclaimIntent(): void { + floatingPanelReclaimIntent = false +} diff --git a/src/renderer/src/lib/floating-workspace-guest-bridge.ts b/src/renderer/src/lib/floating-workspace-guest-bridge.ts new file mode 100644 index 00000000000..9e8430fee5d --- /dev/null +++ b/src/renderer/src/lib/floating-workspace-guest-bridge.ts @@ -0,0 +1,37 @@ +// Guest → mounted-panel bridge for the floating workspace. +// A focused floating *browser* guest's keystrokes never reach the renderer DOM; the main-process +// guest before-input-event routes them over IPC to useIpcEvents, which (after validating the +// source id) re-dispatches them as these typed window events so the mounted panel handles the +// close/select through the exact same closures the keyboard path uses. Dispatch is synchronous, +// so the reclaim intent set inside the panel's close handler still lands before webview teardown. +export const FLOATING_WORKSPACE_GUEST_CLOSE_EVENT = 'orca:floating-workspace-guest-close' +export const FLOATING_WORKSPACE_GUEST_SELECT_INDEX_EVENT = + 'orca:floating-workspace-guest-select-index' + +export type FloatingWorkspaceGuestCloseDetail = { + /** The floating browser guest's owning source page id (Tab.entityId). */ + sourceId: string +} + +export type FloatingWorkspaceGuestSelectIndexDetail = { + /** Zero-based index of the visible floating tab to select. */ + index: number +} + +export function dispatchFloatingWorkspaceGuestClose( + detail: FloatingWorkspaceGuestCloseDetail +): void { + if (typeof window === 'undefined') { + return + } + window.dispatchEvent(new CustomEvent(FLOATING_WORKSPACE_GUEST_CLOSE_EVENT, { detail })) +} + +export function dispatchFloatingWorkspaceGuestSelectIndex( + detail: FloatingWorkspaceGuestSelectIndexDetail +): void { + if (typeof window === 'undefined') { + return + } + window.dispatchEvent(new CustomEvent(FLOATING_WORKSPACE_GUEST_SELECT_INDEX_EVENT, { detail })) +} diff --git a/src/renderer/src/lib/floating-workspace-shortcut-policy.ts b/src/renderer/src/lib/floating-workspace-shortcut-policy.ts index 96c3a0c4200..f31c5cf4565 100644 --- a/src/renderer/src/lib/floating-workspace-shortcut-policy.ts +++ b/src/renderer/src/lib/floating-workspace-shortcut-policy.ts @@ -1,5 +1,6 @@ import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingMatchOptions, type KeybindingOverrides, @@ -14,13 +15,16 @@ type FloatingWorkspaceShortcutEvent = Partial< Pick & { doubleTapModifier?: PhysicalModifierToken } const FLOATING_WORKSPACE_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' -const FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS: readonly KeybindingActionId[] = [ +const FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS = [ 'tab.newTerminal', 'tab.newBrowser', 'tab.newMarkdown', 'tab.openMarkdown', 'tab.close' -] +] as const satisfies readonly KeybindingActionId[] + +export type FloatingWorkspacePanelOwnedAction = + (typeof FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS)[number] function defaultIsMacPlatform(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -40,6 +44,23 @@ export function isFloatingWorkspacePanelShortcutTarget( ) } +// Which tab-creation/close action the panel claims, without the target gate — the panel's own +// window-capture dispatch owns chords from anywhere inside the panel (xterm, browser slot, editor), +// not just its shortcut surface. Returning the identity lets one pass serve both the claim check and +// the dispatch branch. +export function matchFloatingWorkspacePanelOwnedAction( + event: FloatingWorkspaceShortcutEvent, + platform: NodeJS.Platform, + keybindings: KeybindingOverrides | undefined, + options: KeybindingMatchOptions +): FloatingWorkspacePanelOwnedAction | null { + return ( + FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS.find((actionId) => + keybindingMatchesAction(actionId, event, platform, keybindings, options) + ) ?? null + ) +} + export function isFloatingWorkspacePanelShortcut( event: FloatingWorkspaceShortcutEvent, platformOrIsMac: NodeJS.Platform | boolean = defaultIsMacPlatform(), @@ -52,7 +73,75 @@ export function isFloatingWorkspacePanelShortcut( } const platform: NodeJS.Platform = typeof platformOrIsMac === 'boolean' ? (platformOrIsMac ? 'darwin' : 'linux') : platformOrIsMac - return FLOATING_WORKSPACE_PANEL_SHORTCUT_ACTIONS.some((actionId) => - keybindingMatchesAction(actionId, event, platform, keybindings, options) - ) + return matchFloatingWorkspacePanelOwnedAction(event, platform, keybindings, options) !== null +} + +export type FloatingWorkspacePanelShortcutMatch = + | { readonly kind: 'action'; readonly action: KeybindingActionId } + | { readonly kind: 'index'; readonly index: number } + +// Single source of truth for the panel's non-creation shortcut claims (title rename, indexed switch, +// window max/min) so the dispatch, its keydown preflight, and App.tsx's yield gate can't drift and +// silently reintroduce the routing bug. Index precedence is workspace-then-tab, mirroring the +// main-window resolver. Chrome actions take their own options (under terminal-first a focused xterm +// resolves them in app context); the index match uses `options`. +export function matchFloatingWorkspacePanelShortcut( + event: FloatingWorkspaceShortcutEvent, + platform: NodeJS.Platform, + keybindings: KeybindingOverrides | undefined, + options: KeybindingMatchOptions, + chromeOptions: KeybindingMatchOptions = options +): FloatingWorkspacePanelShortcutMatch | null { + if (keybindingMatchesAction('tab.rename', event, platform, keybindings, chromeOptions)) { + return { kind: 'action', action: 'tab.rename' } + } + const index = + matchKeybindingDigitIndex('workspace.selectByIndex', event, platform, keybindings, options) ?? + matchKeybindingDigitIndex('tab.selectByIndex', event, platform, keybindings, options) + if (index !== null) { + return { kind: 'index', index } + } + if ( + keybindingMatchesAction( + 'floatingWorkspace.maximize', + event, + platform, + keybindings, + chromeOptions + ) + ) { + return { kind: 'action', action: 'floatingWorkspace.maximize' } + } + if ( + keybindingMatchesAction( + 'floatingWorkspace.minimize', + event, + platform, + keybindings, + chromeOptions + ) + ) { + return { kind: 'action', action: 'floatingWorkspace.minimize' } + } + return null +} + +// Everything the panel claims, resolved in one call for claim-only callers like App.tsx's yield gate +// (which otherwise pairs the two matchers by hand and can drift from the dispatch). Creation chords +// stay target-gated; chrome chords stay ungated — same order and semantics as the matchers it composes. +export function matchFloatingWorkspacePanelChord( + event: FloatingWorkspaceShortcutEvent, + platform: NodeJS.Platform, + panelRoot: HTMLElement | null, + keybindings: KeybindingOverrides | undefined, + options: KeybindingMatchOptions, + chromeOptions: KeybindingMatchOptions = options +): FloatingWorkspacePanelShortcutMatch | null { + const ownedAction = isFloatingWorkspacePanelShortcutTarget(event.target, panelRoot) + ? matchFloatingWorkspacePanelOwnedAction(event, platform, keybindings, options) + : null + if (ownedAction) { + return { kind: 'action', action: ownedAction } + } + return matchFloatingWorkspacePanelShortcut(event, platform, keybindings, options, chromeOptions) } diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts index d50711e6663..2c556a4c32e 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts @@ -12,9 +12,11 @@ import { isFloatingWorkspacePanelShortcutTarget, isFloatingWorkspaceTerminalInputTarget, isFloatingWorkspacePanelVisible, + matchFloatingWorkspacePanelChord, shouldMinimizeFloatingWorkspacePanelOnCloseShortcut, switchFloatingWorkspaceTab } from './floating-workspace-terminal-actions' +import { matchFloatingWorkspacePanelOwnedAction } from './floating-workspace-shortcut-policy' const activateWebRuntimeSessionTabMock = vi.hoisted(() => vi.fn()) const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) @@ -332,6 +334,105 @@ describe('isFloatingWorkspacePanelShortcut', () => { }) }) +describe('matchFloatingWorkspacePanelOwnedAction', () => { + beforeEach(() => { + installFakeHTMLElement() + }) + + it('returns the matched action so one pass serves the claim check and the dispatch branch', () => { + expect( + matchFloatingWorkspacePanelOwnedAction( + shortcutEvent({ key: 't', code: 'KeyT', ctrlKey: true, target: makeElement({}) }), + 'linux', + undefined, + { context: 'app' } + ) + ).toBe('tab.newTerminal') + expect( + matchFloatingWorkspacePanelOwnedAction( + shortcutEvent({ key: 'w', code: 'KeyW', ctrlKey: true, target: makeElement({}) }), + 'linux', + undefined, + { context: 'app' } + ) + ).toBe('tab.close') + }) + + it('does not gate on the event target — the panel owns these chords from any pane it hosts', () => { + expect( + matchFloatingWorkspacePanelOwnedAction( + shortcutEvent({ key: 'b', code: 'KeyB', target: makeElement({}) }), + 'linux', + undefined, + { context: 'app' } + ) + ).toBeNull() + }) +}) + +describe('matchFloatingWorkspacePanelChord', () => { + beforeEach(() => { + installFakeHTMLElement() + }) + + it('claims a creation chord from the shortcut surface', () => { + expect( + matchFloatingWorkspacePanelChord( + shortcutSurfaceEvent({ key: 't', code: 'KeyT', ctrlKey: true }), + 'linux', + null, + undefined, + { context: 'app' } + ) + ).toEqual({ kind: 'action', action: 'tab.newTerminal' }) + }) + + it('keeps creation chords target-gated', () => { + expect( + matchFloatingWorkspacePanelChord( + shortcutEvent({ key: 't', code: 'KeyT', ctrlKey: true, target: makeElement({}) }), + 'linux', + null, + undefined, + { context: 'app' } + ) + ).toBeNull() + }) + + it('claims indexed switching and rename regardless of the event target', () => { + expect( + matchFloatingWorkspacePanelChord( + shortcutEvent({ key: '2', code: 'Digit2', ctrlKey: true, target: makeElement({}) }), + 'linux', + null, + undefined, + { context: 'app' } + ) + ).toEqual({ kind: 'index', index: 1 }) + expect( + matchFloatingWorkspacePanelChord( + shortcutEvent({ key: 'r', code: 'KeyR', metaKey: true, target: makeElement({}) }), + 'darwin', + null, + undefined, + { context: 'app' } + ) + ).toEqual({ kind: 'action', action: 'tab.rename' }) + }) + + it('does not claim chords the panel has no shortcut for', () => { + expect( + matchFloatingWorkspacePanelChord( + shortcutSurfaceEvent({ key: 'b', code: 'KeyB', ctrlKey: true }), + 'linux', + null, + undefined, + { context: 'app' } + ) + ).toBeNull() + }) +}) + describe('createFloatingWorkspaceTerminalTab', () => { beforeEach(() => { activateWebRuntimeSessionTabMock.mockReset() diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.ts index 35c312b0b93..d1133e7fcfa 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.ts @@ -18,7 +18,9 @@ export { } from './floating-workspace-tab-creation' export { isFloatingWorkspacePanelShortcut, - isFloatingWorkspacePanelShortcutTarget + isFloatingWorkspacePanelShortcutTarget, + matchFloatingWorkspacePanelChord, + matchFloatingWorkspacePanelShortcut } from './floating-workspace-shortcut-policy' type FloatingWorkspaceTabSwitchMode = 'same-type' | 'all-types' | 'terminal' @@ -85,6 +87,14 @@ function getFloatingWorkspaceVisibleTabs( ) } +// Live count of visible floating tabs from store state — lets close handlers re-derive "did this +// actually empty the panel?" at the moment the close resolves, instead of trusting a frozen +// pre-close render snapshot that a concurrent create/no-op close can invalidate. +export function countVisibleFloatingWorkspaceItems(store: FloatingWorkspaceTabSwitchStore): number { + const group = getActiveFloatingWorkspaceGroup(store) + return group ? getFloatingWorkspaceVisibleTabs(store, group).length : 0 +} + function getFloatingWorkspaceActiveEntry( visibleTabs: readonly TypeCyclableTab[], group: TabGroup @@ -123,6 +133,28 @@ function getFloatingWorkspaceBrowserTab( ) } +// The guest IPC receiver maps a forwarded close's source id back to the live floating browser +// workspace that owns it, so a stale/reordered/closed id is an idempotent no-op. Main forwards the +// guest's *page* id (BrowserManager keys guests by browserPageId), while the panel closes by +// workspace id, so resolve pages → workspace here; a workspace id is accepted too for callers that +// already speak that id space. +export function resolveFloatingWorkspaceBrowserWorkspaceId( + store: Pick, + sourceId: string +): string | null { + const workspaces = store.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const pagesByWorkspace = store.browserPagesByWorkspace ?? {} + for (const workspace of workspaces) { + if (workspace.id === sourceId) { + return workspace.id + } + if ((pagesByWorkspace[workspace.id] ?? []).some((page) => page.id === sourceId)) { + return workspace.id + } + } + return null +} + function activateFloatingWorkspaceCyclableTab( store: FloatingWorkspaceTabSwitchStore, next: TypeCyclableTab @@ -178,12 +210,18 @@ export function isEmptyFloatingWorkspacePanelVisible( } export function isFloatingWorkspacePanelFocused( - doc: Pick = document + doc: Pick | null = typeof document === 'undefined' ? null : document ): boolean { - const active = doc.activeElement + const active = doc?.activeElement return active instanceof HTMLElement && active.closest(FLOATING_WORKSPACE_PANEL_SELECTOR) !== null } +// Event-target-aware panel membership (vs isFloatingWorkspacePanelFocused which reads only activeElement). +// Used for routing ownership when activeElement is transiently body/null during blur/IME churn (F6/F7). +export function isEventTargetInsideFloatingWorkspacePanel(target: EventTarget | null): boolean { + return target instanceof HTMLElement && target.closest(FLOATING_WORKSPACE_PANEL_SELECTOR) !== null +} + export function isFloatingWorkspaceTerminalInputTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts index 260cd2ad657..8f08c1b30cc 100644 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ b/src/renderer/src/lib/github-work-item-background-create.ts @@ -29,8 +29,7 @@ import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' -import type { GitHubWorkItem, SetupDecision } from '../../../shared/types' -import type { Repo } from '../../../shared/types' +import type { GitHubWorkItem, SetupDecision, Repo } from '../../../shared/types' import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' @@ -242,12 +241,13 @@ export async function createGitHubWorkItemWorkspaceInBackground( if (!deps.hasPendingCreate(creationId)) { return { kind: 'background-started' } } - const { startupPlan, quickPrompt, quickTelemetry } = buildGitHubWorkItemStartupPlan({ - agent, - item: args.item, - repo, - store - }) + const { startupPlan, quickPrompt, launchDraftPrompt, quickTelemetry } = + buildGitHubWorkItemStartupPlan({ + agent, + item: args.item, + repo, + store + }) if (agent && !startupPlan) { deps.toastError(agentLaunchCommandErrorMessage()) abandonStagedCreate(creationId, restoreView, deps) @@ -311,6 +311,7 @@ export async function createGitHubWorkItemWorkspaceInBackground( ...(issueCommand ? { issueCommand } : {}), startupPlan, quickPrompt, + ...(launchDraftPrompt ? { launchDraftPrompt } : {}), quickTelemetry } diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts index 86cf3c82961..b8a761f4611 100644 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ b/src/renderer/src/lib/github-work-item-background-request.ts @@ -174,11 +174,14 @@ export function buildGitHubWorkItemStartupPlan(args: { }): { startupPlan: AgentStartupPlan | null quickPrompt: string + /** Draft context (issue link) that reaches only the TUI input; callers thread + * it onto the creation request so completion can seed the chat composer. */ + launchDraftPrompt: string | null quickTelemetry: AgentStartedTelemetry | null } { const { agent, item, repo, store } = args if (!agent) { - return { startupPlan: null, quickPrompt: '', quickTelemetry: null } + return { startupPlan: null, quickPrompt: '', launchDraftPrompt: null, quickTelemetry: null } } const { prompt: quickPrompt, draftPrompt } = resolveGitHubWorkItemPrompt(item) // Why: runtime-owned repos launch on their owner host, not on the client @@ -233,6 +236,7 @@ export function buildGitHubWorkItemStartupPlan(args: { return { startupPlan, quickPrompt, + launchDraftPrompt: draftPrompt || null, quickTelemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: 'new_workspace_composer', diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts index 63fafaaae55..4e2e561ed5e 100644 --- a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts +++ b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts @@ -59,6 +59,24 @@ describe('queueHookCommandsForFirstWorktreeTab', () => { expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1') }) + it('keeps every delivery queued for the same worktree', () => { + // A runtime-owned create can have setup/issue commands AND an agent-tab seed + // waiting on the same first mirrored tab; neither may evict the other. + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: {} }) + const deliverFirst = vi.fn() + const deliverSecond = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver: deliverFirst }) + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver: deliverSecond }) + + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } }) + + expect(deliverFirst).toHaveBeenCalledTimes(1) + expect(deliverSecond).toHaveBeenCalledTimes(1) + expect(deliverSecond).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1') + }) + it('drops the pending delivery when the worktree is no longer known', () => { setStorePartial({ tabsByWorktree: {}, diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts index 5f2db5d0cd2..1dfdd7c1c3b 100644 --- a/src/renderer/src/lib/hook-command-delayed-delivery.ts +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -12,13 +12,21 @@ type PendingWorktreeHookCommandDelivery = { // the delivery until the first mirrored terminal tab lands instead of // dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription // shape: subscribed only while something is pending. -const pendingHookCommandDeliveries = new Map() +// Queued per worktree rather than one-per-worktree: a runtime-owned create can +// have both setup/issue commands and an agent-tab seed waiting on the same +// first mirrored tab, and neither may evict the other. +const pendingHookCommandDeliveries = new Map() let unsubscribePendingHookCommandDeliveries: (() => void) | null = null export function queueHookCommandsForFirstWorktreeTab( delivery: PendingWorktreeHookCommandDelivery ): void { - pendingHookCommandDeliveries.set(delivery.worktreeId, delivery) + const queued = pendingHookCommandDeliveries.get(delivery.worktreeId) + if (queued) { + queued.push(delivery) + } else { + pendingHookCommandDeliveries.set(delivery.worktreeId, [delivery]) + } ensurePendingHookCommandSubscription() flushPendingHookCommandDeliveries() } @@ -72,7 +80,7 @@ function stopPendingHookCommandSubscriptionIfIdle(): void { function flushPendingHookCommandDeliveries(): void { const state = useAppStore.getState() - for (const [worktreeId, delivery] of pendingHookCommandDeliveries) { + for (const [worktreeId, deliveries] of pendingHookCommandDeliveries) { const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id if (!firstTerminalTabId) { // Why: a worktree can be removed before its tabs ever mirror; drop the @@ -83,9 +91,11 @@ function flushPendingHookCommandDeliveries(): void { continue } // Delete before delivering so store writes inside deliver cannot re-enter - // this entry through the subscription. + // these entries through the subscription. pendingHookCommandDeliveries.delete(worktreeId) - delivery.deliver(state, firstTerminalTabId) + for (const delivery of deliveries) { + delivery.deliver(state, firstTerminalTabId) + } } stopPendingHookCommandSubscriptionIfIdle() } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts new file mode 100644 index 00000000000..c4034ba5bee --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -0,0 +1,332 @@ +// Windows/WSL shell-quoting coverage for launchAgentInNewTab, split from +// launch-agent-in-new-tab.test.ts to keep both files within the lines budget. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockQueueTabStartupCommand = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + terminalWindowsShell?: string + }, + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), + transientClearedAgentStatusConnectionIds: {} as Record, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }] + }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record< + string, + { activeLeafId: string | null; ptyIdsByLeafId?: Record } + >, + ptyIdsByTabId: {} as Record, + createTab: mockCreateTab, + closeTab: vi.fn(), + queueTabStartupCommand: mockQueueTabStartupCommand, + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('sonner', () => ({ + toast: { message: vi.fn(), error: vi.fn() } +})) + +vi.mock('@/components/tab-bar/reconcile-order', () => ({ + reconcileTabOrder: vi.fn( + (_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [ + ...termIds, + ...editorIds, + ...browserIds + ] + ) +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) + +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) + +describe('launchAgentInNewTab Windows shell quoting', () => { + beforeEach(() => { + vi.clearAllMocks() + store.activeRepoId = 'repo-1' + store.activeWorktreeId = 'wt-1' + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null + } + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + store.transientClearedAgentStatusConnectionIds = {} + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + } + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + store.openFiles = [] + store.browserTabsByWorktree = {} + store.tabBarOrderByWorktree = {} + store.terminalLayoutsByTabId = {} + store.ptyIdsByTabId = {} + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockPasteDraftWhenAgentReady.mockResolvedValue(true) + }) + + it('uses the explicit startup shell platform when building draft launch commands', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" + }) + ) + }) + + it('quotes local Windows default agent args for cmd.exe empty launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'claude "--dangerously-skip-permissions"' + }) + ) + }) + + it('keeps PowerShell quoting for local Windows default agent args', async () => { + store.settings.terminalWindowsShell = 'powershell.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions'" + }) + ) + }) + + it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'fix the spinner', + agentArgs: '--model gpt-5', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: 'codex "--model" "gpt-5" "fix the spinner"', + agentArgsOverride: '--model gpt-5' + }) + ) + }) + + it('quotes local Windows draft launches for Git Bash', async () => { + store.settings.terminalWindowsShell = 'git-bash' + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft', + launchPlatform: 'win32' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + }) + ) + }) + + it('does not use the local Windows shell setting for remote Windows launches', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions'" + }) + ) + }) + + it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\Users\\jinwo\\repo\\feature', + displayName: 'feature' + } + ] + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + }) + ) + }) + + // Platform resolution lands on posix here because vitest's node environment does not + // report Windows. This pins single-quote escaping of user-configured default agent args. + it('escapes a single quote inside default agent args', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.settings.agentDefaultArgs = { codex: '--profile "don\'t"' } + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\Users\\jinwo\\repo\\feature', + displayName: 'feature' + } + ] + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as { command: string } + expect(queued.command).toContain("'don'\\''t'") + expect(queued.command).not.toContain("'don''t'") + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 35d069935c8..6b9de384945 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -8,6 +8,7 @@ const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockSeedNativeChatLaunchPrompt = vi.fn() +const mockSeedNativeChatLaunchDraft = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() @@ -80,6 +81,7 @@ const store = { setTabBarOrder: mockSetTabBarOrder, setAgentStatus: mockSetAgentStatus, seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed } @@ -441,157 +443,6 @@ describe('launchAgentInNewTab', () => { expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) - it('uses the explicit startup shell platform when building draft launch commands', async () => { - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: "review Bob's change", - promptDelivery: 'draft', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" - }) - ) - }) - - it('quotes local Windows default agent args for cmd.exe empty launches', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: 'claude "--dangerously-skip-permissions"' - }) - ) - }) - - it('keeps PowerShell quoting for local Windows default agent args', async () => { - store.settings.terminalWindowsShell = 'powershell.exe' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" - }) - ) - }) - - it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'fix the spinner', - agentArgs: '--model gpt-5', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: 'codex "--model" "gpt-5" "fix the spinner"', - agentArgsOverride: '--model gpt-5' - }) - ) - }) - - it('quotes local Windows draft launches for Git Bash', async () => { - store.settings.terminalWindowsShell = 'git-bash' - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: "review Bob's change", - promptDelivery: 'draft', - launchPlatform: 'win32' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" - }) - ) - }) - - it('does not use the local Windows shell setting for remote Windows launches', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }] - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions'" - }) - ) - }) - - it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => { - store.settings.terminalWindowsShell = 'cmd.exe' - store.projects = [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] - store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] - store.worktreesByRepo = { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - projectId: 'repo-1', - path: 'C:\\Users\\jinwo\\repo\\feature', - displayName: 'feature' - } - ] - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: "review Bob's change", - promptDelivery: 'draft' - }) - - expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ - command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" - }) - ) - }) - it('falls back to post-ready draft paste when a Windows inline draft would be too large', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const prompt = 'x'.repeat(25_000) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 55586d811c6..a31475d1016 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ updateWorktreeMeta: vi.fn(), setSidebarOpen: vi.fn(), seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchPromptFailed: vi.fn(), activateAndRevealWorktree: vi.fn(), pasteDraftWhenAgentReady: vi.fn(), @@ -24,6 +25,7 @@ const mocks = vi.hoisted(() => ({ updateWorktreeMeta: ReturnType setSidebarOpen: ReturnType seedNativeChatLaunchPrompt: ReturnType + seedNativeChatLaunchDraft: ReturnType markNativeChatLaunchPromptFailed: ReturnType } })) @@ -191,6 +193,7 @@ describe('launchWorkItemDirect', () => { updateWorktreeMeta: mocks.updateWorktreeMeta, setSidebarOpen: mocks.setSidebarOpen, seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed } as typeof mocks.store // @ts-expect-error -- test shim @@ -428,6 +431,62 @@ describe('launchWorkItemDirect', () => { expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled() }) + it('seeds the chat-composer launch draft for a GitHub issue draft launch', async () => { + mocks.ensureDetectedAgents.mockResolvedValue(['claude']) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + item: { + type: 'issue', + number: 12, + title: 'Fix crash on launch', + url: 'https://github.com/acme/repo/issues/12' + } + }) + ).resolves.toBe(true) + + // The issue link prefills only the TUI input (argv `--prefill`); the seeded + // draft is what makes the same context visible in the chat view. + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'claude', + text: 'https://github.com/acme/repo/issues/12', + createdAt: expect.any(Number) + }) + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + + it('withholds the chat-composer launch draft for a multi-line Linear draft launch', async () => { + // A Linear draft is always `Linked Linear issue: ENG-42\n\n`. The chat + // send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so seeding it + // would leave the first line parked to glue onto the next message. + mocks.ensureDetectedAgents.mockResolvedValue(['claude']) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + item: { + type: 'issue', + number: null, + title: 'Ship Linear parity', + url: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', + linearIdentifier: 'ENG-42' + } + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + it('preserves explicit Linear paste content submit-after-ready behavior', async () => { mocks.ensureDetectedAgents.mockResolvedValue(['claude']) const { launchWorkItemDirect } = await import('./launch-work-item-direct') @@ -472,6 +531,7 @@ describe('launchWorkItemDirect', () => { text: 'Use this explicit user prompt.', createdAt: expect.any(Number) }) + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) it('uses remote cursor-agent detection, trust preflight, and paste launch for SSH repos', async () => { diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 61f49274edb..0981f8d40b1 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -13,6 +13,7 @@ import { workspaceActivationErrorMessage } from '@/lib/launch-work-item-direct-messages' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { getConnectionId } from '@/lib/connection-context' import type { GitPushTarget, SetupDecision, TuiAgent } from '../../../shared/types' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' @@ -239,8 +240,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } if (effectiveAgent) { // Why: direct task launch creates and starts the workspace in separate - // steps so agent detection can overlap git worktree creation. Persist - // the chosen agent once known so empty-worktree reopen can recreate it. + // steps so agent detection can overlap git worktree creation. Persist the + // chosen agent once known so removal safety and ownership see it — reopen + // no longer relaunches from this field. void store.updateWorktreeMeta(worktreeId, { createdWithAgent: effectiveAgent }).catch(() => { // Non-critical: activation still has the explicit startup below. }) @@ -307,6 +309,17 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } + // Why: draft delivery lands only in the TUI input buffer (argv prefill or + // startup-owned paste); seed the chat-composer copy so the work-item context + // isn't invisible in the GUI view. + if (promptDelivery === 'draft' && primaryTabId && effectiveAgent) { + seedNativeChatLaunchDraftForAgentTab({ + tabId: primaryTabId, + agent: effectiveAgent, + text: draftContent + }) + } + // Why: at this point the workspace is live and the agent (if any) has // been queued on `primaryTabId`. The post-launch paste step below only // applies to agents that lacked a native prefill flag; for agents that diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts index 270a27f7a5b..bffbd1537a3 100644 --- a/src/renderer/src/lib/native-chat-launch-prompt.ts +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -7,3 +7,17 @@ export type NativeChatLaunchPrompt = { createdAt: number failed?: boolean } + +/** + * Launch-time context delivered as an UNSENT draft (e.g. a linked issue URL + * prefilled into the agent TUI's input buffer). The chat composer adopts it as + * its own draft so the context isn't invisible in the GUI view. + */ +export type NativeChatLaunchDraft = { + tabId: string + agent: TuiAgent + text: string + createdAt: number + /** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */ + adopted?: boolean +} diff --git a/src/renderer/src/lib/open-in-application-selection.ts b/src/renderer/src/lib/open-in-application-selection.ts new file mode 100644 index 00000000000..09ccaca9bb9 --- /dev/null +++ b/src/renderer/src/lib/open-in-application-selection.ts @@ -0,0 +1,3 @@ +import type { OpenInApplication } from '../../../shared/types' + +export const NO_OPEN_IN_APPLICATIONS: readonly OpenInApplication[] = [] diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 2c1fd67de6d..4f409f16628 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -1,5 +1,4 @@ -import type { IDisposable, IMarker, Terminal } from '@xterm/xterm' -import type { ITerminalOptions } from '@xterm/xterm' +import type { IDisposable, IMarker, Terminal, ITerminalOptions } from '@xterm/xterm' import type { FitAddon } from '@xterm/addon-fit' import type { LigaturesAddon } from '@xterm/addon-ligatures' import type { SearchAddon } from '@xterm/addon-search' diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index b7f042a7b36..f8b37ae438c 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -87,6 +87,9 @@ export type WorktreeCreationRequest = { * did not already spawn it. Null for blank-shell creates. */ startupPlan: AgentStartupPlan | null quickPrompt: string + /** Launch context delivered only as an unsent TUI-input draft (argv prefill or + * startup paste); completion seeds the chat-composer copy from it. */ + launchDraftPrompt?: string quickTelemetry: AgentStartedTelemetry | null /** When the composer stays open for sequential creates, completion must not * steal focus from the next workspace name field. */ diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 34c88bec363..ed1dbe5f5e1 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -15,8 +15,7 @@ // process (`repoSlug` reads `.git/config`). import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' -import type { Repo } from '../../../shared/types' -import type { GlobalSettings } from '../../../shared/types' +import type { Repo, GlobalSettings } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { clearRepoSlugCacheValues, diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 1fb14546ce8..6daa375c8ed 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -41,6 +41,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState rightSidebarExplorerView: 'files', rightSidebarWidth: 350, markdownTocPanelWidth: 240, + combinedDiffFileTreeWidth: 256, groupBy: 'repo', sortBy: 'name', projectOrderBy: 'manual', diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 14af5e911f9..d40bab3f4eb 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -115,6 +115,35 @@ describe('buildAgentStartupPlan', () => { }) }) + it('passes the prompt to Trae as a positional argv behind a `--` separator', () => { + expect( + buildAgentStartupPlan({ + agent: 'trae', + prompt: 'Summarize the failing tests', + cmdOverrides: {}, + platform: 'linux' + }) + ).toEqual({ + agent: 'trae', + launchCommand: "traecli -- 'Summarize the failing tests'", + expectedProcess: 'traecli', + followupPrompt: null, + launchConfig: emptyLaunchConfig('traecli') + }) + }) + + // Why: without the separator these dispatch to Trae's `help`/`config` subcommands instead. + it('keeps subcommand-shaped Trae prompts as the positional prompt', () => { + expect( + buildAgentStartupPlan({ + agent: 'trae', + prompt: 'help me name this config', + cmdOverrides: {}, + platform: 'linux' + })?.launchCommand + ).toBe("traecli -- 'help me name this config'") + }) + it('uses cursor-agent as the actual launch binary', () => { expect( buildAgentStartupPlan({ diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts new file mode 100644 index 00000000000..693af72e69c --- /dev/null +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes +} from './workspace-emoji-shortcodes' + +describe('workspace emoji shortcodes', () => { + it('finds standard emoji by Slack-style shortcode', () => { + expect(searchWorkspaceEmojiShortcodes('wink', 1)).toEqual([{ emoji: '😉', shortcode: 'wink' }]) + }) + + it('ranks an exact shortcode before longer aliases', () => { + expect(searchWorkspaceEmojiShortcodes('heart', 3)[0]).toMatchObject({ + shortcode: 'heart' + }) + }) + + it('deduplicates aliases that resolve to the same emoji', () => { + const suggestions = searchWorkspaceEmojiShortcodes('wink') + expect(new Set(suggestions.map((suggestion) => suggestion.emoji)).size).toBe(suggestions.length) + }) + + it('detects a shortcode at the caret without matching URLs or mid-word colons', () => { + expect(getActiveWorkspaceEmojiShortcode('Ship :win', 9)).toEqual({ + start: 5, + end: 9, + query: 'win' + }) + expect(getActiveWorkspaceEmojiShortcode('https://example.com', 19)).toBeNull() + expect(getActiveWorkspaceEmojiShortcode('fix:win', 7)).toBeNull() + }) + + it('replaces a completed shortcode and preserves text after the caret', () => { + expect(replaceCompletedWorkspaceEmojiShortcode('Ship :wink: today', 11)).toEqual({ + value: 'Ship 😉 today', + cursor: 7 + }) + }) + + it('leaves unknown completed shortcodes unchanged', () => { + expect(replaceCompletedWorkspaceEmojiShortcode(':orca_custom:', 13)).toBeNull() + }) + + it('applies a selected suggestion to the active shortcode range', () => { + expect( + applyWorkspaceEmojiSuggestion( + 'Ship :win today', + { start: 5, end: 9, query: 'win' }, + { emoji: '😉', shortcode: 'wink' } + ) + ).toEqual({ + value: 'Ship 😉 today', + cursor: 7 + }) + }) +}) diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts new file mode 100644 index 00000000000..e97d0267192 --- /dev/null +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -0,0 +1,112 @@ +import { + STANDARD_EMOJI_SHORTCODE_ENTRIES, + type StandardEmojiShortcodeEntry +} from '../../../shared/emoji-shortcode-catalog' + +export type WorkspaceEmojiSuggestion = StandardEmojiShortcodeEntry + +export type ActiveWorkspaceEmojiShortcode = { + end: number + query: string + start: number +} + +export type WorkspaceEmojiReplacement = { + cursor: number + value: string +} + +const SHORTCODE_ENTRIES = STANDARD_EMOJI_SHORTCODE_ENTRIES + +const EXACT_SHORTCODE = new Map( + SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) +) + +export function searchWorkspaceEmojiShortcodes( + query: string, + limit = 8 +): WorkspaceEmojiSuggestion[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery || limit <= 0) { + return [] + } + + const matches = SHORTCODE_ENTRIES.filter(({ shortcode }) => + shortcode.startsWith(normalizedQuery) + ).sort( + (left, right) => + Number(right.shortcode === normalizedQuery) - Number(left.shortcode === normalizedQuery) || + left.shortcode.length - right.shortcode.length || + left.shortcode.localeCompare(right.shortcode) + ) + const seenEmoji = new Set() + const suggestions: WorkspaceEmojiSuggestion[] = [] + for (const { emoji, shortcode } of matches) { + if (seenEmoji.has(emoji)) { + continue + } + seenEmoji.add(emoji) + suggestions.push({ emoji, shortcode }) + if (suggestions.length === limit) { + break + } + } + return suggestions +} + +export function getActiveWorkspaceEmojiShortcode( + value: string, + cursor: number | null +): ActiveWorkspaceEmojiShortcode | null { + if (cursor === null || cursor < 0 || cursor > value.length) { + return null + } + const match = value.slice(0, cursor).match(/(^|\s):([a-z0-9_+-]{1,40})$/i) + if (!match) { + return null + } + return { + start: cursor - match[2].length - 1, + end: cursor, + query: match[2].toLowerCase() + } +} + +export function replaceCompletedWorkspaceEmojiShortcode( + value: string, + cursor: number | null +): WorkspaceEmojiReplacement | null { + if (cursor === null || cursor < 0 || cursor > value.length) { + return null + } + const match = value.slice(0, cursor).match(/(^|\s):([a-z0-9_+-]{1,40}):$/i) + if (!match) { + return null + } + const suggestion = EXACT_SHORTCODE.get(match[2].toLowerCase()) + if (!suggestion) { + return null + } + const start = cursor - match[2].length - 2 + return replaceWorkspaceEmojiRange(value, start, cursor, suggestion.emoji) +} + +export function applyWorkspaceEmojiSuggestion( + value: string, + active: ActiveWorkspaceEmojiShortcode, + suggestion: WorkspaceEmojiSuggestion +): WorkspaceEmojiReplacement { + return replaceWorkspaceEmojiRange(value, active.start, active.end, suggestion.emoji) +} + +function replaceWorkspaceEmojiRange( + value: string, + start: number, + end: number, + emoji: string +): WorkspaceEmojiReplacement { + return { + value: `${value.slice(0, start)}${emoji}${value.slice(end)}`, + cursor: start + emoji.length + } +} diff --git a/src/renderer/src/lib/workspace-session-hydration-keys.ts b/src/renderer/src/lib/workspace-session-hydration-keys.ts index c4e4ac9d00b..1166d8702a1 100644 --- a/src/renderer/src/lib/workspace-session-hydration-keys.ts +++ b/src/renderer/src/lib/workspace-session-hydration-keys.ts @@ -6,6 +6,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' export type WorkspaceSessionHydrationOptions = { additionalValidWorkspaceKeys?: readonly WorkspaceKey[] + replaceWorkspaceKeys?: readonly string[] } // Worktree-keyed fields carrying restorable chrome — a repo appears here only if it has live diff --git a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts b/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts index 68196ad5f55..5cd35181547 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts @@ -27,9 +27,74 @@ export function makeCreatedAgentWorktree(): Worktree { } } +type StoreState = ReturnType + +/** The empty-workspace store shape both seeds start from; each layers its own tabs/actions on top. */ +function baseSeedState(worktree: Worktree, worktrees: Worktree[]): Partial { + return { + repos: [ + { + id: worktree.repoId, + path: path.join(path.sep, 'workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: worktrees }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + settings: { + agentCmdOverrides: {}, + setupScriptLaunchMode: 'new-tab' + } as unknown as StoreState['settings'], + refreshGitHubForWorktreeIfStale: vi.fn() + } +} + +/** Seeds a `createdWithAgent` worktree with zero renderable tabs — the state that used to + * trigger the removed creation-agent relaunch. */ +export function seedEmptyActivatableWorktree( + worktree: Worktree, + options: { extraWorktrees?: Worktree[] } = {} +): { revealWorktreeInSidebar: ReturnType } { + const revealWorktreeInSidebar = vi.fn() + + useAppStore.setState({ + ...baseSeedState(worktree, [...(options.extraWorktrees ?? []), worktree]), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + revealWorktreeInSidebar + }) + + // Why: orphan terminals and reconnectable PTYs also feed renderableTabCount, so + // assert the premise — drift here would make the regression tests pass blind. + const { renderableTabCount } = useAppStore.getState().reconcileWorktreeTabModel(worktree.id) + if (renderableTabCount !== 0) { + throw new Error( + `seedEmptyActivatableWorktree: expected 0 renderable tabs, got ${renderableTabCount}` + ) + } + + return { revealWorktreeInSidebar } +} + export function seedAlreadyActiveWorktree( worktree: Worktree, - overrides: Partial> = {} + overrides: Partial = {} ): { markWorktreeVisited: ReturnType recordWorktreeVisit: ReturnType @@ -39,21 +104,9 @@ export function seedAlreadyActiveWorktree( const recordWorktreeVisit = vi.fn() const revealWorktreeInSidebar = vi.fn() const terminalTitle = ['Terminal', '1'].join(' ') - const repoPath = path.join(path.sep, 'workspace', 'repo') useAppStore.setState({ - repos: [ - { - id: worktree.repoId, - path: repoPath, - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0 - } - ], - worktreesByRepo: { [worktree.repoId]: [worktree] }, - activeRepoId: worktree.repoId, - activeView: 'terminal', + ...baseSeedState(worktree, [worktree]), activeWorktreeId: worktree.id, activeTabId: 'tab-1', activeTabType: 'terminal', @@ -101,19 +154,9 @@ export function seedAlreadyActiveWorktree( activeGroupIdByWorktree: { [worktree.id]: 'group-1' }, activeTabTypeByWorktree: { [worktree.id]: 'terminal' }, everActivatedWorktreeIds: new Set([worktree.id]), - openFiles: [], - browserTabsByWorktree: {}, - activeFileIdByWorktree: {}, - activeBrowserTabIdByWorktree: {}, activeTabIdByWorktree: { [worktree.id]: 'tab-1' }, - tabBarOrderByWorktree: {}, - settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], markWorktreeVisited, recordWorktreeVisit, - refreshGitHubForWorktreeIfStale: vi.fn(), revealWorktreeInSidebar, ...overrides }) diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 93da1bb423e..cc2af6e7d7c 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -9,7 +9,8 @@ import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-sess import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn' import { makeCreatedAgentWorktree as makeWorktree, - seedAlreadyActiveWorktree + seedAlreadyActiveWorktree, + seedEmptyActivatableWorktree } from '@/lib/worktree-activation-created-agent-test-state' const initialAppStoreState = useAppStore.getState() @@ -22,6 +23,19 @@ function makeWebRuntimeWorktree() { } } +/** Activates and asserts a focusable tab appeared with no queued startup, returning its id. */ +function activateAndExpectNoRelaunch( + worktreeId: string, + opts?: Parameters[1] +): string { + const result = activateAndRevealWorktree(worktreeId, opts) + const tabId = result === false ? undefined : (result.primaryTabId ?? undefined) + + expect(tabId).toBeDefined() + expect(useAppStore.getState().pendingStartupByTabId[tabId!]).toBeUndefined() + return tabId! +} + afterEach(() => { delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ vi.unstubAllGlobals() @@ -30,7 +44,7 @@ afterEach(() => { useAppStore.setState(initialAppStoreState, true) }) -describe('activateAndRevealWorktree created agent reopen', () => { +describe('activateAndRevealWorktree', () => { it('does not restamp focus recency when reselecting the already-active terminal worktree', () => { const worktree = makeWorktree() const { markWorktreeVisited, recordWorktreeVisit, revealWorktreeInSidebar } = @@ -57,133 +71,69 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(recordWorktreeVisit).toHaveBeenCalledWith(worktree.id) }) - it('reopens an empty worktree with the agent selected at creation time', () => { + it('does not relaunch the creation-time agent when reopening an empty worktree', () => { const worktree = makeWorktree() - const revealWorktreeInSidebar = vi.fn() - - useAppStore.setState({ - repos: [ - { - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0 - } - ], - worktreesByRepo: { 'repo-1': [worktree] }, - activeRepoId: 'repo-1', - activeView: 'terminal', - tabsByWorktree: {}, - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - layoutByWorktree: {}, - activeGroupIdByWorktree: {}, - openFiles: [], - browserTabsByWorktree: {}, - activeFileIdByWorktree: {}, - activeBrowserTabIdByWorktree: {}, - activeTabTypeByWorktree: {}, - activeTabIdByWorktree: {}, - tabBarOrderByWorktree: {}, - pendingStartupByTabId: {}, - settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], - markWorktreeVisited: vi.fn(), - recordWorktreeVisit: vi.fn(), - refreshGitHubForWorktreeIfStale: vi.fn(), - revealWorktreeInSidebar - }) + const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree) const result = activateAndRevealWorktree(worktree.id) const state = useAppStore.getState() const reopenedTab = state.tabsByWorktree[worktree.id]?.[0] + // A focusable surface still appears — it is just a plain shell, with no queued agent launch. expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() - expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: "codex '--dangerously-bypass-approvals-and-sandbox'", - env: {}, - launchAgent: 'codex', - launchConfig: { - agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'", - agentArgs: '--dangerously-bypass-approvals-and-sandbox', - agentEnv: {} - }, - launchToken: expect.any(String), - sessionOptions: undefined, - telemetry: { - agent_kind: 'codex', - launch_source: 'sidebar', - request_kind: 'resume' - } - }) + expect(state.pendingStartupByTabId[reopenedTab!.id]).toBeUndefined() expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) - it('uses WSL launch quoting when reopening a Windows-path WSL project agent', () => { - const worktree = { - ...makeWorktree(), - path: 'C:\\Users\\jinwo\\repo\\feature' + it('does not relaunch on repeated activate/close cycles', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + for (let cycle = 0; cycle < 3; cycle += 1) { + activateAndExpectNoRelaunch(worktree.id) + + // Return to zero tabs, the state that used to re-arm the relaunch. Sets state + // directly rather than via closeTab — the sleeping-record purge is covered in + // worktree-reactivation-tab-forkbomb.test.ts. + useAppStore.setState({ tabsByWorktree: {}, activeTabIdByWorktree: {} }) } + }) - useAppStore.setState({ - projects: [ - { - id: 'repo-1', - displayName: 'repo', - badgeColor: '#000000', - sourceRepoIds: ['repo-1'], - createdAt: 0, - updatedAt: 0, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ], - repos: [ - { - id: 'repo-1', - path: 'C:\\Users\\jinwo\\repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0 - } - ], - worktreesByRepo: { 'repo-1': [worktree] }, - activeRepoId: 'repo-1', - activeView: 'terminal', - tabsByWorktree: {}, - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - layoutByWorktree: {}, - activeGroupIdByWorktree: {}, - openFiles: [], - browserTabsByWorktree: {}, - activeFileIdByWorktree: {}, - activeBrowserTabIdByWorktree: {}, - activeTabTypeByWorktree: {}, - activeTabIdByWorktree: {}, - tabBarOrderByWorktree: {}, - pendingStartupByTabId: {}, - settings: { - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--profile "don\'t"' }, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], - markWorktreeVisited: vi.fn(), - recordWorktreeVisit: vi.fn(), - refreshGitHubForWorktreeIfStale: vi.fn(), - revealWorktreeInSidebar: vi.fn() + it('does not relaunch when activating a sibling worktree the user never opened', () => { + const sibling = makeWorktree() + const target = { ...makeWorktree(), id: 'wt-handoff', displayName: 'handoff' } + seedEmptyActivatableWorktree(target, { extraWorktrees: [sibling] }) + + // The shape post-delete focus handoff produces. That caller passes no opts at all — + // asserted directly in active-worktree-focus-after-delete.test.ts. + activateAndExpectNoRelaunch(target.id) + }) + + it('does not relaunch when activation opts carry no startup payload', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + // The opts shape CLI/relay navigation and notification clicks arrive with; those + // callers are asserted in useIpcEvents.test.ts. The host's `didSpawnStartup` leg is + // a main-process concern and is not reachable from here. + activateAndExpectNoRelaunch(worktree.id, { notifyHostRuntime: false }) + }) + + it('still queues an explicit startup supplied by the caller', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + const result = activateAndRevealWorktree(worktree.id, { + startup: { command: 'codex' } }) - - const result = activateAndRevealWorktree(worktree.id) const state = useAppStore.getState() - const reopenedTab = state.tabsByWorktree[worktree.id]?.[0] + const tabId = result === false ? undefined : (result.primaryTabId ?? undefined) - expect(result).toEqual({ primaryTabId: reopenedTab?.id }) - expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).toContain("'don'\\''t'") - expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).not.toContain("'don''t'") + expect(tabId).toBeDefined() + expect(state.pendingStartupByTabId[tabId!]).toEqual( + expect.objectContaining({ command: 'codex' }) + ) }) it('does not duplicate a sleeping agent session owned by a preserved slept pane', () => { diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index b3c9abe7fb7..ea06052d992 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -5,7 +5,6 @@ import type { SetupSplitDirection, Tab, TuiAgent, - Worktree, WorktreeDefaultTabsLaunch, WorktreeSetupLaunch } from '../../../shared/types' @@ -19,10 +18,6 @@ import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-t import { buildSetupRunnerCommand } from './setup-runner' import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' -import { buildAgentStartupPlan } from './tui-agent-startup' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' -import { CLIENT_PLATFORM } from './new-workspace' -import { tuiAgentToAgentKind } from './telemetry' import { agentKindToTuiAgent } from '../../../shared/agent-kind' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -42,15 +37,8 @@ import { setWorktreeNavActivator, setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../shared/tui-agent-launch-defaults' -import { isTuiAgent } from '../../../shared/tui-agent-config' -import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree, type WorktreeRuntimeOwnerState @@ -67,7 +55,6 @@ import { getConnectionId } from '@/lib/connection-context' import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' -import { resolveNativeChatSessionOptionDefaults } from '../../../shared/native-chat-session-option-defaults' import type { SessionOptionValue } from '../../../shared/native-chat-session-options' /** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started` @@ -229,56 +216,6 @@ export function activateAndRevealFolderWorkspace( return { primaryTabId } } -function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayload | undefined { - const agent = worktree.createdWithAgent - if (!isTuiAgent(agent)) { - return undefined - } - - const state = useAppStore.getState() - const repo = state.repos.find((entry) => entry.id === worktree.repoId) - const launchPlatform = repo - ? getAgentLaunchPlatformForRepo( - repo, - repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(state, worktree.id) - ) - : CLIENT_PLATFORM - - const startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), - sessionOptions: resolveNativeChatSessionOptionDefaults( - state.settings?.nativeChatSessionOptions, - agent - ), - platform: launchPlatform, - isRemote: repo ? repoIsRemote(repo) : false, - allowEmptyPromptLaunch: true - }) - if (!startupPlan) { - return undefined - } - - return { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - telemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: 'sidebar', - request_kind: 'resume' - } - } -} - export function activateAndRevealWorktree( worktreeId: string, opts?: { @@ -346,7 +283,7 @@ export function activateAndRevealWorktree( const primaryTabId = ensureWorktreeHasInitialTerminal( useAppStore.getState(), worktreeId, - opts?.startup ?? buildCreatedAgentReopenStartup(wt), + opts?.startup, opts?.setup, opts?.issueCommand, opts?.defaultTabs diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts new file mode 100644 index 00000000000..21f1b76fcf8 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' +import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent-seeds' + +const mocks = vi.hoisted(() => ({ + seedNativeChatAppliedSessionOptions: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn() +})) + +vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ + seedNativeChatAppliedSessionOptions: mocks.seedNativeChatAppliedSessionOptions +})) +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab +})) + +type AppState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById + +const DRAFT = 'https://github.com/o/r/issues/12' + +const request = { + agent: 'claude' as const, + startupPlan: { agent: 'claude', launchCommand: 'claude' } as never, + launchDraftPrompt: DRAFT +} + +function setTabs(tabs: { id: string; launchAgent?: string }[]): void { + useAppStore.setState({ + tabsByWorktree: { 'wt-1': tabs }, + getKnownWorktreeById: ((id: string) => + id === 'wt-1' ? { id } : undefined) as unknown as AppState['getKnownWorktreeById'] + } as unknown as Partial) +} + +function seededTabIds(): string[] { + return mocks.seedNativeChatLaunchDraftForAgentTab.mock.calls.map((call) => call[0].tabId) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + resetHookCommandDelayedDeliveryForTests() + useAppStore.setState({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById + } as Partial) +}) + +describe('seedAgentTabStateAfterWorktreeCreate', () => { + it('seeds the backend-spawned startup tab, not the first default terminal tab', () => { + // Repo default tabs (dev server / logs / shell) run no agent; on the + // backend-spawn path none of them carries launchAgent either. + setTabs([{ id: 'dev-server' }, { id: 'logs' }, { id: 'agent-tab' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'dev-server', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(seededTabIds()).toEqual(['agent-tab']) + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith( + 'agent-tab', + 'claude', + undefined + ) + }) + + it('seeds the launchAgent-stamped tab when the renderer owns startup', () => { + setTabs([{ id: 'dev-server' }, { id: 'agent-tab', launchAgent: 'claude' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'dev-server', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(seededTabIds()).toEqual(['agent-tab']) + }) + + it('still seeds primaryTabId on the ordinary local path', () => { + setTabs([{ id: 'tab-1' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(seededTabIds()).toEqual(['tab-1']) + }) + + it('never falls back to an unrelated first tab', () => { + setTabs([{ id: 'dev-server' }, { id: 'logs' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + }) + + it('defers the seed to the first mirrored tab on runtime-owned worktrees', () => { + // Runtime-owned (web session) worktrees mirror their session tabs async, so + // the worktree has no tab at all at seed time. + setTabs([]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + + setTabs([{ id: 'mirror-tab-1' }]) + + expect(seededTabIds()).toEqual(['mirror-tab-1']) + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith( + 'mirror-tab-1', + 'claude', + undefined + ) + }) + + it('prefers the agent-stamped tab over the first mirrored tab when both land', () => { + setTabs([]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + + setTabs([{ id: 'mirror-shell' }, { id: 'mirror-agent', launchAgent: 'claude' }]) + + expect(seededTabIds()).toEqual(['mirror-agent']) + }) + + it('drops the deferred seed when the mirrored tabs are ambiguous', () => { + // Repo default tabs mirror together and none is stamped: `tabs[0]` here is + // "dev server". Seeding it would be withheld from mobile by the agent check + // and ignored on desktop — the same invariant the synchronous path holds. + setTabs([]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: undefined, + backendSpawned: false + }) + + setTabs([{ id: 'mirror-dev-server' }, { id: 'mirror-logs' }]) + + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled() + }) + + it('seeds session options but no draft without launch draft context', () => { + setTabs([{ id: 'tab-1' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { agent: 'claude', startupPlan: request.startupPlan }, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledOnce() + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + }) + + it('does nothing without an agent or a startup plan', () => { + setTabs([{ id: 'tab-1' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { agent: null, startupPlan: request.startupPlan, launchDraftPrompt: DRAFT }, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + seedAgentTabStateAfterWorktreeCreate({ + request: { agent: 'claude', startupPlan: null, launchDraftPrompt: DRAFT }, + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startupTerminalTabId: undefined, + backendSpawned: false + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts new file mode 100644 index 00000000000..a5173934332 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -0,0 +1,96 @@ +import { useAppStore } from '@/store' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import type { TuiAgent } from '../../../shared/types' + +type AppStoreSnapshot = ReturnType + +type SeedRequest = Pick + +/** + * Resolve the tab the launch agent actually runs in. + * + * Deliberately never falls back to `tabs[0]`: with repo default terminal tabs + * (e.g. "dev server", "logs", "shell") the worktree's first tab runs no agent, + * and activation's `primaryTabId` is only the worktree's primary tab. + */ +function resolveLaunchAgentTabId( + state: AppStoreSnapshot, + args: { + agent: TuiAgent + worktreeId: string + primaryTabId: string | null + startupTerminalTabId: string | null | undefined + backendSpawned: boolean + } +): string | null { + // Main spawned the agent itself, so its startup tab is authoritative. + if (args.backendSpawned && args.startupTerminalTabId) { + return args.startupTerminalTabId + } + const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? [] + const stamped = worktreeTabs.find((tab) => tab.launchAgent === args.agent)?.id + // Why: when the renderer owns startup, `ensureAgentStartupInTerminal` queues it + // on primaryTabId, so that tab is the agent's tab by construction. + return stamped ?? args.primaryTabId ?? args.startupTerminalTabId ?? null +} + +function applyAgentTabSeeds(request: SeedRequest, agent: TuiAgent, tabId: string): void { + seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions) + // Why: draft launch context reaches only the TUI input; seed the + // chat-composer copy so it isn't invisible in the chat view. + if (request.launchDraftPrompt) { + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: request.launchDraftPrompt }) + } +} + +/** + * Seed per-tab agent state (applied session options + the chat-composer copy of + * draft launch context) once a created worktree's launch tab is known. + */ +export function seedAgentTabStateAfterWorktreeCreate(args: { + request: SeedRequest + worktreeId: string + primaryTabId: string | null + startupTerminalTabId: string | null | undefined + backendSpawned: boolean +}): void { + const { request, worktreeId } = args + const agent = request.agent + if (!request.startupPlan || !agent) { + return + } + const state = useAppStore.getState() + const tabId = resolveLaunchAgentTabId(state, { ...args, agent }) + if (tabId) { + applyAgentTabSeeds(request, agent, tabId) + return + } + if ((state.tabsByWorktree[worktreeId] ?? []).length > 0) { + // Tabs exist but none is the agent's; seeding one anyway would publish the + // draft on a tab that runs no agent. + return + } + // Why: runtime-owned (web session) worktrees mirror their session tabs async, + // so there is no tab to seed yet — hold the seed for the first mirrored tab + // instead of dropping it and leaving the draft invisible on that host class. + queueHookCommandsForFirstWorktreeTab({ + worktreeId, + deliver: (state, firstTerminalTabId) => { + const mirroredTabId = resolveLaunchAgentTabId(state, { ...args, agent }) + if (mirroredTabId) { + applyAgentTabSeeds(request, agent, mirroredTabId) + return + } + // Why: same invariant as the synchronous branch — never seed a tab that + // runs no agent. The queue entry is consumed before delivery (no retry), + // so accept the first mirrored tab only when it is the worktree's only + // one and therefore unambiguously the agent's. + if ((state.tabsByWorktree[worktreeId] ?? []).length === 1) { + applyAgentTabSeeds(request, agent, firstTerminalTabId) + } + } + }) +} diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 90af3fe69b9..512b4c742c4 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -39,7 +39,9 @@ const store = { setSidebarOpen: vi.fn(), createWorktree: vi.fn(() => new Promise(() => {})), setupProjectExistingFolder: vi.fn(), - refreshRuntimeEnvironmentStatus: vi.fn() + refreshRuntimeEnvironmentStatus: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + tabsByWorktree: {} as Record } vi.mock('@/store', () => ({ @@ -97,6 +99,7 @@ beforeEach(() => { store.repos = [] store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(makeRequest()) } store.createWorktree.mockImplementation(() => new Promise(() => {})) + store.tabsByWorktree = {} vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') }) @@ -627,6 +630,140 @@ describe('staged background worktree creation', () => { expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() }) + it('seeds the chat-composer launch draft on completion for draft launches', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'claude', + startupPlan: { + agent: 'claude', + launchCommand: 'claude --prefill x', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + }, + launchDraftPrompt: 'https://github.com/o/r/issues/12' + }) + ) + + expect(started).toBe(true) + await vi.waitFor(() => + expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: expect.any(Number) + }) + ) + }) + + it('seeds the backend-spawned agent tab, not the worktree default terminal tab', async () => { + // Repo default tabs ("dev server", "logs", …) make activation's primaryTabId + // a tab that runs no agent; main's startup terminal is the agent's own tab. + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.tabsByWorktree = { 'wt-1': [{ id: 'dev-server' }, { id: 'agent-tab' }] } + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }, + startupTerminal: { tabId: 'agent-tab', spawned: true } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'dev-server' }) + + continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'claude', + startupPlan: { + agent: 'claude', + launchCommand: 'claude --prefill x', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + }, + launchDraftPrompt: 'https://github.com/o/r/issues/12' + }) + ) + + await vi.waitFor(() => expect(store.seedNativeChatLaunchDraft).toHaveBeenCalled()) + expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'agent-tab' }) + ) + }) + + it('does not seed a launch draft without draft launch context', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'claude', + startupPlan: { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + ) + + await vi.waitFor(() => expect(activateAndRevealWorktree).toHaveBeenCalled()) + expect(store.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + + // Why: activation no longer rebuilds a startup from `createdWithAgent`, so this + // caller's own `startup` is the only thing that launches the agent it created. + it('passes its own startup to activation when the create requested an agent', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1' } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agent: 'codex', command: 'codex' }, + draftPrompt: 'ship it' + } as never + }) + ) + + expect(started).toBe(true) + await vi.waitFor(() => + expect(activateAndRevealWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + startup: expect.objectContaining({ + command: 'codex', + launchAgent: 'codex', + draftPrompt: 'ship it' + }) + }) + ) + ) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + }) + it('toasts a staged create error after the user leaves the creation surface', async () => { store.activeView = 'tasks' store.createWorktree.mockRejectedValueOnce(new Error('create failed')) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index dcfc25a33d1..7b7099f293d 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -25,7 +25,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' -import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean @@ -255,16 +255,13 @@ async function executeWorktreeCreation( // Why: clearing synchronously right after activation lets React commit the // panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash. useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false }) - if (preparedRequest.startupPlan && preparedRequest.agent) { - const optionScopeKey = primaryTabId ?? result.startupTerminal?.tabId - if (optionScopeKey) { - seedNativeChatAppliedSessionOptions( - optionScopeKey, - preparedRequest.agent, - preparedRequest.startupPlan.sessionOptions - ) - } - } + seedAgentTabStateAfterWorktreeCreate({ + request: preparedRequest, + worktreeId: worktree.id, + primaryTabId, + startupTerminalTabId: result.startupTerminal?.tabId, + backendSpawned + }) if (preparedRequest.startupPlan && !backendSpawned) { void ensureAgentStartupInTerminal({ worktreeId: worktree.id, diff --git a/src/renderer/src/lib/worktree-operation-generation.ts b/src/renderer/src/lib/worktree-operation-generation.ts index 04a2bd3bfc6..67a09302371 100644 --- a/src/renderer/src/lib/worktree-operation-generation.ts +++ b/src/renderer/src/lib/worktree-operation-generation.ts @@ -1,6 +1,8 @@ import { parseExecutionHostId } from '../../../shared/execution-host' -import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' -import { getEnvironmentSshTargetConnectionGeneration } from '@/store/slices/runtime-environment-ssh' +import { + getEnvironmentSshStateGeneration, + getEnvironmentSshTargetConnectionGeneration +} from '@/store/slices/runtime-environment-ssh' import { getLocalSshTargetConnectionGeneration } from '@/store/slices/ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index 7059ee4ad51..4cb69b75e77 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -86,6 +86,23 @@ describe('worktree-palette-search', () => { ]) }) + it('finds an emoji-named workspace by its readable branch shortcode', () => { + const results = searchWorktrees( + [makeWorktree({ displayName: '🚀', branch: 'refs/heads/rocket' })], + 'rocket', + repoMap, + null, + null + ) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + worktreeId: 'wt-1', + matchedField: 'branch', + branchRange: { start: 0, end: 6 } + }) + }) + it('rejects oversized pasted queries before reading worktree metadata', () => { const oversizedQuery = 'secret-worktree-palette-search'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES) const worktree = { diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index acdd17dcb36..b0da9b8b1b4 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { SshProviderEpoch } from '../../../shared/ssh-types' import { subscribeRuntimeClientEvents } from './runtime-client-events' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' @@ -137,7 +138,7 @@ describe('subscribeRuntimeClientEvents', () => { ]) }) - it('applies the redacted SSH snapshot from the ready frame', async () => { + it('preserves full SSH authority in retained snapshots and live client events', async () => { let capturedOnResponse: ((response: unknown) => void) | undefined const subscribe = vi.fn(async (_args, nextCallbacks) => { capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse @@ -160,9 +161,11 @@ describe('subscribeRuntimeClientEvents', () => { targetId: 'ssh-1', state: { targetId: 'ssh-1', - status: 'disconnected', + status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + providerEpoch: 'snapshot-provider-epoch' as SshProviderEpoch, + connectionGeneration: 17 } } ] @@ -175,10 +178,74 @@ describe('subscribeRuntimeClientEvents', () => { targetId: 'ssh-1', state: { targetId: 'ssh-1', - status: 'disconnected', + status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + providerEpoch: 'snapshot-provider-epoch', + connectionGeneration: 17 + } + }) + + capturedOnResponse({ + ok: true, + result: { + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'live-provider-epoch' as SshProviderEpoch, + connectionGeneration: 18 + } + } + }) + + expect(onEvent).toHaveBeenLastCalledWith({ + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'live-provider-epoch', + connectionGeneration: 18 } }) }) + + it('rejects a partial runtime authority instead of retaining it', async () => { + let capturedOnResponse: ((response: unknown) => void) | undefined + const subscribe = vi.fn(async (_args, nextCallbacks) => { + capturedOnResponse = (nextCallbacks as { onResponse: (response: unknown) => void }).onResponse + return { subscriptionId: 'sub-1', unsubscribe: vi.fn() } + }) + const onEvent = vi.fn() + const onError = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } }) + await subscribeRuntimeClientEvents('env-1', onEvent, onError) + if (!capturedOnResponse) { + throw new Error('Expected subscription callbacks') + } + + capturedOnResponse({ + ok: true, + result: { + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' + } + } + }) + + expect(onEvent).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })) + }) }) diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 0b28e7c822d..0f1b72371a9 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/runtime-client-events' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' +import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' export type RuntimeClientEventSubscription = { @@ -53,13 +54,27 @@ function handleRuntimeClientEventResponse( const message = response.result as RuntimeClientEventStreamMessage if (message.type === 'ready') { for (const sshState of message.snapshot?.sshStates ?? []) { - onEvent({ type: 'sshStateChanged', ...sshState }) + const state = admitSshConnectionState(sshState.state, sshState.targetId) + if (state) { + onEvent({ type: 'sshStateChanged', targetId: sshState.targetId, state }) + } else { + onError(new Error('Invalid retained SSH connection state')) + } } return } if (message.type === 'end') { return } + if (message.type === 'sshStateChanged') { + const state = admitSshConnectionState(message.state, message.targetId) + if (state) { + onEvent({ type: 'sshStateChanged', targetId: message.targetId, state }) + } else { + onError(new Error('Invalid retained SSH connection state')) + } + return + } if (isRuntimeClientEvent(message)) { onEvent(message) } diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts index 5cecc0961ff..46567c5343c 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshConnectionState } from '../../../shared/ssh-types' +import type { SshConnectionState, SshProviderEpoch } from '../../../shared/ssh-types' import { useAppStore } from '@/store' import { applyRuntimeEnvironmentSshStateChanged, @@ -20,7 +20,14 @@ function connState( targetId: string, status: SshConnectionState['status'] = 'connected' ): SshConnectionState { - return { targetId, status, error: null, reconnectAttempt: 0 } + return { + targetId, + status, + error: null, + reconnectAttempt: 0, + providerEpoch: `${targetId}-provider-epoch` as SshProviderEpoch, + connectionGeneration: 7 + } } type RpcResponses = { @@ -84,6 +91,10 @@ describe('hydrateRuntimeEnvironmentSshState', () => { expect(bucket?.targetLabels.get('ssh-1')).toBe('devbox') expect(bucket?.removedTargetLabels.get('ssh-old')).toBe('retired box') expect(bucket?.connectionStates.get('ssh-1')?.status).toBe('connected') + expect(bucket?.connectionStates.get('ssh-1')).toMatchObject({ + providerEpoch: 'ssh-1-provider-epoch', + connectionGeneration: 7 + }) // ssh-2 had no live state: absent, so reads fall back to 'disconnected'. expect(bucket?.connectionStates.has('ssh-2')).toBe(false) // Local maps stay untouched. @@ -219,13 +230,36 @@ describe('applyRuntimeEnvironmentSshStateChanged', () => { applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-1', connState('ssh-1', 'disconnected')) expect( - useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status - ).toBe('disconnected') + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1') + ).toMatchObject({ + status: 'disconnected', + providerEpoch: 'ssh-1-provider-epoch', + connectionGeneration: 7 + }) expect(callRuntimeRpcMock).not.toHaveBeenCalled() // Local map untouched. expect(useAppStore.getState().sshConnectionStates.size).toBe(0) }) + it('rejects partial authority before retaining a runtime-owned state', () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-1', label: 'devbox' }]) + + applyRuntimeEnvironmentSshStateChanged(envId, 'ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' as SshProviderEpoch + }) + + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.has('ssh-1') + ).toBe(false) + }) + it('does not touch another environment bucket or local state (no cross-pollution)', () => { const envA = nextEnvId() const envB = nextEnvId() @@ -300,6 +334,12 @@ describe('connectRuntimeEnvironmentSshTarget', () => { expect( useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1')?.status ).toBe('connected') + expect( + useAppStore.getState().sshStateByEnvironment.get(envId)?.connectionStates.get('ssh-1') + ).toMatchObject({ + providerEpoch: 'ssh-1-provider-epoch', + connectionGeneration: 7 + }) expect(useAppStore.getState().sshConnectionStates.size).toBe(0) }) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts index c63fb0cf3ba..07051882292 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-types' import { callRuntimeRpc } from './runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' /** * Mirrors a remote Orca server's own SSH targets into that environment's @@ -71,10 +72,11 @@ async function fetchEnvironmentSshConnectionStates( { targetId: target.id }, { timeoutMs: SSH_RPC_TIMEOUT_MS } ) - if (state) { + const admittedState = state ? admitSshConnectionState(state, target.id) : null + if (admittedState) { useAppStore .getState() - .setEnvironmentSshConnectionState(environmentId, target.id, state, generation) + .setEnvironmentSshConnectionState(environmentId, target.id, admittedState, generation) } } catch { // Why: a timeout or unsupported RPC is not authoritative evidence that the HUB's SSH link disconnected. @@ -157,10 +159,14 @@ export function applyRuntimeEnvironmentSshStateChanged( if (generation !== getEnvironmentSshStateGeneration(environmentId)) { return } + const admittedState = admitSshConnectionState(state, targetId) + if (!admittedState) { + return + } const store = useAppStore.getState() const bucket = store.sshStateByEnvironment.get(environmentId) if (bucket?.targetsHydrated && bucket.targetLabels.has(targetId)) { - store.setEnvironmentSshConnectionState(environmentId, targetId, state, generation) + store.setEnvironmentSshConnectionState(environmentId, targetId, admittedState, generation) return } void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) @@ -180,12 +186,13 @@ export async function connectRuntimeEnvironmentSshTarget( { targetId }, { timeoutMs: 60_000 } ) - if (state) { + const admittedState = state ? admitSshConnectionState(state, targetId) : null + if (admittedState) { useAppStore .getState() - .setEnvironmentSshConnectionState(environmentId, targetId, state, generation) + .setEnvironmentSshConnectionState(environmentId, targetId, admittedState, generation) } - return state + return admittedState } /** Resyncs the environment's target metadata after a failed connect so a diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index 45bb77dd32c..741d381e11f 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -96,6 +96,32 @@ export async function inspectRuntimeTerminalProcess( } } +/** + * Forces a fresh, uncached foreground scan for a pane whose cached inspection + * is suspect (issue #11064: the cached read can flap to the shell for a live + * agent). Local/daemon panes only — runtime environments expose no fresh-scan + * RPC, and an SSH provider without confirm support answers null, which callers + * must read as "no new evidence", never as a shell confirmation. + */ +export async function confirmRuntimeTerminalForegroundProcess( + settings: Pick | null | undefined, + ptyId: string +): Promise { + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + const target = ownerEnvironmentId + ? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const) + : getActiveRuntimeTarget(settings) + if (target.kind === 'environment' && getRemoteRuntimeTerminalHandle(ptyId)) { + return null + } + const confirmForegroundProcess = window.api.pty.confirmForegroundProcess + // Why the shape check: a preload older than this handler has no such method. + if (typeof confirmForegroundProcess !== 'function') { + return null + } + return confirmForegroundProcess(ptyId).catch(() => null) +} + export function sendRuntimePtyInput( settings: Pick | null | undefined, ptyId: string, diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index d1082d89cab..8d3a149e583 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -386,6 +386,48 @@ describe('getRuntimeMobileSessionSyncKey', () => { expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false) }) + it('changes when a native-chat launch draft is seeded or cleared', () => { + const sharedOverrides = makeSharedOverrides() + const launchDraft = { + tabId: 'term-1', + agent: 'claude' as const, + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + + const before = getRuntimeMobileSessionSyncKey( + makeState({ ...sharedOverrides, nativeChatLaunchDraftByTabId: {} }) + ) + const after = getRuntimeMobileSessionSyncKey( + makeState({ + ...sharedOverrides, + nativeChatLaunchDraftByTabId: { 'term-1': launchDraft } + }) + ) + + expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false) + }) + + it('does not skip the App subscriber gate when a launch draft is seeded', () => { + // The key is never even built when this gate skips, so the draft-aware key + // case above cannot catch a regression here. + const sharedOverrides = makeSharedOverrides() + const before = makeState({ ...sharedOverrides, nativeChatLaunchDraftByTabId: {} }) + const after = makeState({ + ...sharedOverrides, + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude' as const, + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + } + }) + + expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false) + }) + it('changes when explicit agent status epoch changes', () => { const sharedOverrides = makeSharedOverrides() const before = getRuntimeMobileSessionSyncKey( @@ -669,6 +711,73 @@ describe('buildMobileSessionTabSnapshots', () => { ) }) + it('publishes the native-chat launch draft on terminal surface tabs', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'claude' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as unknown as AppState['terminalLayoutsByTabId'], + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + } + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + parentTabId: 'term-1', + launchDraft: 'https://github.com/o/r/issues/12' + }) + ]) + }) + + it('withholds a launch draft seeded for a different agent than the tab runs', () => { + // The seed is keyed by tab id, which survives an agent switch. Desktop's + // consumer declines on mismatch; publishing anyway would prefill the new + // agent's mobile chat with the previous agent's link. + const leafId = '11111111-1111-4111-8111-111111111111' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'codex' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as unknown as AppState['terminalLayoutsByTabId'], + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude', + text: 'https://github.com/o/r/issues/12', + createdAt: 1 + } + } + }) + + const snapshot = buildMobileSessionTabSnapshots(state)[0] + + expect(snapshot?.tabs[0]).not.toHaveProperty('launchDraft') + }) + it('preserves source-control diff metadata for mobile file tabs', () => { const diffId = 'wt-1::diff::unstaged::src/app.ts' const state = makeState({ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 7d90d25d107..33cb27ed74a 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -245,6 +245,7 @@ export type RuntimeMobileSessionSyncKey = { // Why: compared by reference; reallocation signals a real layout/title change, avoiding stringifying thousands of tabs. See docs/agent-working-pane-typing-lag.md. terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] + nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] groupsByWorktree: AppState['groupsByWorktree'] activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] layoutByWorktree: AppState['layoutByWorktree'] @@ -300,6 +301,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild( state.activeTabId === previousState.activeTabId && state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && + state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && state.agentStatusEpoch === previousState.agentStatusEpoch && state.agentStatusByPaneKey === previousState.agentStatusByPaneKey ) @@ -336,6 +338,7 @@ export function getRuntimeMobileSessionSyncKey( return { terminalLayoutsByTabId: state.terminalLayoutsByTabId, runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, + nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, groupsByWorktree: state.groupsByWorktree, activeGroupIdByWorktree: state.activeGroupIdByWorktree, layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, @@ -580,6 +583,7 @@ export function runtimeMobileSessionSyncKeysEqual( return ( a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && + a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && a.groupsByWorktree === b.groupsByWorktree && a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && a.layoutByWorktree === b.layoutByWorktree && @@ -1363,6 +1367,12 @@ function buildMobileTerminalSurfaceTabs( : undefined const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} const terminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) + // Agent-matched like the desktop consumer: a pane whose agent changed keeps its + // tab id, so an unmatched seed would prefill the new agent's chat with stale text. + const seededLaunchDraft = state.nativeChatLaunchDraftByTabId?.[terminal.id] + const launchDraftEntry = + seededLaunchDraft && seededLaunchDraft.agent === terminal.launchAgent ? seededLaunchDraft : null + const launchDraftText = launchDraftEntry?.text.trim() ? launchDraftEntry.text : null const container = registered?.getContainer() const firstChild = container?.firstElementChild const liveLayoutRoot = serializePaneTree( @@ -1423,6 +1433,9 @@ function buildMobileTerminalSurfaceTabs( ...(terminalTheme ? { terminalTheme } : {}), ...(agentStatus ? { agentStatus } : {}), ...(terminal.launchAgent ? { launchAgent: terminal.launchAgent } : {}), + // Launch context that exists only as an unsent TUI-input draft; mobile + // prefills its chat composer from it (desktop keeps its own seed store). + ...(launchDraftText ? { launchDraft: launchDraftText } : {}), parentLayout, isActive: isDesktopTabActive && leafId === activeLeafId } diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index cb159aaaa91..9bb2b3bedd8 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -14,8 +14,10 @@ import type { } from '../../../shared/runtime-types' import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' -import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { + SleepingAgentLaunchConfig, + AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' import { AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { AgentLaunchPreferences, diff --git a/src/renderer/src/startup/ssh-startup-reconnect.test.ts b/src/renderer/src/startup/ssh-startup-reconnect.test.ts index 28d1dea0435..44872a97f6e 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.test.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SshConnectionState } from '../../../shared/ssh-types' +import type { SshConnectionState, SshProviderEpoch } from '../../../shared/ssh-types' import { reconnectSshTargetForRendererStartup } from './ssh-startup-reconnect' const connectedState: SshConnectionState = { @@ -7,6 +7,8 @@ const connectedState: SshConnectionState = { status: 'connected', error: null, reconnectAttempt: 0, + providerEpoch: 'startup-provider-epoch' as SshProviderEpoch, + connectionGeneration: 41, remotePlatform: 'linux' } @@ -27,6 +29,32 @@ describe('reconnectSshTargetForRendererStartup', () => { expect(result).toEqual({ timedOut: false }) expect(publishState).toHaveBeenCalledWith('ssh-1', connectedState) + expect(publishState.mock.calls[0]?.[1]).toMatchObject({ + providerEpoch: 'startup-provider-epoch', + connectionGeneration: 41 + }) + }) + + it('does not synthesize the missing half of a partial startup authority', async () => { + const publishState = vi.fn() + const partialState = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' as SshProviderEpoch + } satisfies SshConnectionState + + await reconnectSshTargetForRendererStartup({ + targetId: 'ssh-1', + timeoutMs: 1_000, + connect: vi.fn().mockResolvedValue(partialState), + publishState, + onFailure: vi.fn() + }) + + expect(publishState).toHaveBeenCalledWith('ssh-1', partialState) + expect(publishState.mock.calls[0]?.[1]).not.toHaveProperty('connectionGeneration') }) it('marks a stalled connect as deferred without publishing stale state', async () => { diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts index a5c88559ac5..0163aacc4c9 100644 --- a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -49,6 +49,8 @@ globalThis.window = { api: mockApi } import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' @@ -62,6 +64,11 @@ const REMOTE_PTY2_SAME_HANDLE = 'remote:env-2@@terminal-wt1' const PTY2 = 'pty-wt2' function seedMaps(store: ReturnType): void { + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } seedStore(store, { worktreesByRepo: { repo1: [ @@ -133,6 +140,32 @@ function seedMaps(store: ReturnType): void { [PTY1_SPLIT]: true, [REMOTE_PTY1]: true, [PTY2]: true + }, + directSshPaneRetryByTabId: { + [TAB1]: { + attemptId: 'attempt-1' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + startedAt: 1 + } + }, + directSshLivePtyBindingByTabId: { + [TAB1]: { + attemptId: 'attempt-1' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: PTY1 + }, + [TAB2]: { + attemptId: 'attempt-2' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: PTY2 + } + }, + directSshPaneRetryHistoryByTabId: { + [TAB1]: { authority, attemptedAt: [1] }, + [TAB2]: { authority, attemptedAt: [2] } } }) } @@ -163,6 +196,9 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.pendingCodexPaneRestartIds[PTY1]).toBeUndefined() expect(s.pendingCodexPaneRestartIds[PTY1_SPLIT]).toBeUndefined() expect(s.pendingCodexPaneRestartIds[REMOTE_PTY1]).toBeUndefined() + expect(s.directSshPaneRetryByTabId[TAB1]).toBeUndefined() + expect(s.directSshLivePtyBindingByTabId[TAB1]).toBeUndefined() + expect(s.directSshPaneRetryHistoryByTabId[TAB1]).toBeUndefined() // Surviving worktree's tab/pty: every entry retained (no over-eviction). expect(s.lastKnownRelayPtyIdByTabId[TAB2]).toBe(PTY2) @@ -176,6 +212,8 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() expect(s.suppressedPtyExitIds[PTY2]).toBe(true) expect(s.pendingCodexPaneRestartIds[PTY2]).toBe(true) + expect(s.directSshLivePtyBindingByTabId[TAB2]).toBeDefined() + expect(s.directSshPaneRetryHistoryByTabId[TAB2]).toBeDefined() }) it('keeps environment-scoped remote guard identities independent', () => { @@ -244,4 +282,21 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo // exact identities without parsing aliases or scanning surviving terminals. expect(parseRemotePtyId).not.toHaveBeenCalled() }) + + it('preserves direct SSH ledgers when a surviving worktree reuses the tab id', () => { + const store = createTestStore() + seedMaps(store) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WT2]: [...state.tabsByWorktree[WT2], makeTab({ id: TAB1, worktreeId: WT2, ptyId: PTY2 })] + } + })) + + store.getState().purgeWorktreeTerminalState([WT1]) + + expect(store.getState().directSshPaneRetryByTabId[TAB1]).toBeDefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB1]).toBeDefined() + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB1]).toBeDefined() + }) }) diff --git a/src/renderer/src/store/slices/codex-restart-notice-account-identity.ts b/src/renderer/src/store/slices/codex-restart-notice-account-identity.ts new file mode 100644 index 00000000000..e7a95ae8db0 --- /dev/null +++ b/src/renderer/src/store/slices/codex-restart-notice-account-identity.ts @@ -0,0 +1,26 @@ +/** One side of a Codex restart notice: the account's id when the caller knows + * it (`null` is the system default), plus the label shown in the prompt. */ +export type CodexRestartNoticeAccount = { + id?: string | null + label: string +} + +/** + * Decides whether two restart-notice accounts are the same account. + * + * Why not the labels: an account label is an email, and nothing stops two + * accounts from sharing one — the same OpenAI login added under two ChatGPT + * workspaces, or a failed roster read collapsing every account to the same + * fallback string. Deciding on labels there erases a prompt for a pane that is + * genuinely running on the account the user switched away from. Labels remain + * the fallback only for callers that carry no ids at all. + */ +export function isSameCodexRestartNoticeAccount( + a: CodexRestartNoticeAccount, + b: CodexRestartNoticeAccount +): boolean { + if (a.id !== undefined && b.id !== undefined) { + return a.id === b.id + } + return a.label === b.label +} diff --git a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts index 3d78a0353c1..6e8f0ede98f 100644 --- a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts +++ b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts @@ -111,6 +111,22 @@ describe('codex restart notice lifecycle', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId).toBe(before) }) + it('reports which panes are left holding a notice', () => { + expect( + useAppStore + .getState() + .markCodexRestartNotices([{ ptyId: 'pty-1', previousAccountLabel: A, nextAccountLabel: B }]) + ).toEqual(['pty-1']) + + // Why the caller needs this: the startup sweep suppresses a pane for the + // rest of the session once it is told a prompt went up. + expect( + useAppStore + .getState() + .markCodexRestartNotices([{ ptyId: 'pty-1', previousAccountLabel: B, nextAccountLabel: A }]) + ).toEqual([]) + }) + it('still deletes the record when the pane actually restarts', () => { switchAccount('pty-1', A, B) @@ -126,3 +142,63 @@ describe('codex restart notice lifecycle', () => { }) }) }) + +// Why: doAddAccount has no duplicate-email check, so one OpenAI login used in +// two ChatGPT workspaces gives two accounts the same label — as does a failed +// roster read, which collapses every account to 'Codex account'. +describe('codex restart notices with colliding account labels', () => { + const SHARED = 'shared@example.com' + + function switchAccountById(ptyId: string, from: string | null, to: string | null): void { + useAppStore.getState().markCodexRestartNotices([ + { + ptyId, + previousAccountLabel: SHARED, + nextAccountLabel: SHARED, + previousAccountId: from, + nextAccountId: to + } + ]) + } + + it('raises the prompt for two different accounts that read the same', () => { + switchAccountById('pty-1', 'account-a', 'account-b') + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: SHARED, + nextAccountLabel: SHARED, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + }) + + it('still collapses A -> B -> A when the ids match', () => { + switchAccountById('pty-1', 'account-a', 'account-b') + switchAccountById('pty-1', 'account-b', 'account-a') + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() + }) + + it('collapses back to the system default the pane launched under', () => { + switchAccountById('pty-1', null, 'account-b') + switchAccountById('pty-1', 'account-b', null) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() + }) + + it('re-asks a dismissed pane when a same-labelled third account is selected', () => { + switchAccountById('pty-1', 'account-a', 'account-b') + useAppStore.getState().dismissCodexRestartNotices(['pty-1']) + + switchAccountById('pty-1', 'account-b', 'account-c') + + // Why: carrying the dismissal on the shared label would strand the pane on + // account-a with its prompt answered for an account it never moved to. + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: SHARED, + nextAccountLabel: SHARED, + previousAccountId: 'account-a', + nextAccountId: 'account-c' + }) + }) +}) diff --git a/src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts b/src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts new file mode 100644 index 00000000000..1e0dd54a008 --- /dev/null +++ b/src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' +import { + createDetectedWorktreeRefreshLeaseRegistry, + type DetectedWorktreeRefreshProviderInput +} from './detected-worktree-refresh-leases' + +type ControlledPromise = { + promise: Promise + resolve(value: T): void + reject(error: unknown): void +} + +function controlledPromise(): ControlledPromise { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function authority(providerEpoch: string, connectionGeneration = 1): DirectSshAuthority { + return { + targetId: 'ssh-a', + providerEpoch: providerEpoch as SshProviderEpoch, + connectionGeneration + } +} + +function directInput( + expectedAuthority = authority('epoch-a') +): DetectedWorktreeRefreshProviderInput { + return { + repoId: 'repo-a', + executionHostId: 'ssh:ssh-a', + expectedAuthority + } +} + +function completeResult(request: ListDetectedWorktreesArgs): HostQualifiedDetectedWorktreeResult { + if (!('expectedAuthority' in request)) { + throw new Error('Expected direct SSH request') + } + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { + kind: 'direct-ssh', + executionHostId: request.executionHostId, + ...request.expectedAuthority + }, + result: { + repoId: request.repoId, + authoritative: true, + source: 'git', + worktrees: [] + } + } +} + +function createHarness() { + const pending: ControlledPromise[] = [] + const starts: ListDetectedWorktreesArgs[] = [] + const cancelProviderRequest = vi.fn() + const registry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: (request) => { + starts.push(request) + const provider = controlledPromise() + pending.push(provider) + return provider.promise + }, + cancelProviderRequest + }) + return { cancelProviderRequest, pending, registry, starts } +} + +describe('detected worktree refresh leases', () => { + it('joins exact inputs with distinct waiter and shared provider identities', () => { + const { registry, starts } = createHarness() + + const first = registry.acquire('repo-a:ssh:ssh-a', directInput()) + const second = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(starts).toHaveLength(1) + expect(first.providerRequestId).toBe(second.providerRequestId) + expect(first.waiterLeaseId).not.toBe(second.waiterLeaseId) + expect(first.waiterLeaseId).not.toBe(first.providerRequestId) + expect(starts[0]).not.toHaveProperty('waiterLeaseId') + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 2 + }) + }) + + it('settles only the released waiter while another lease remains live', async () => { + const { cancelProviderRequest, pending, registry, starts } = createHarness() + const first = registry.acquire('repo-a:ssh:ssh-a', directInput()) + const second = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(first.release('superseded')).toBe('retained') + await expect(first.result).resolves.toEqual({ + providerRequestId: first.providerRequestId, + executionHostId: 'ssh:ssh-a', + status: 'canceled' + }) + expect(cancelProviderRequest).not.toHaveBeenCalled() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 1 + }) + + const providerResult = completeResult(starts[0]) + pending[0].resolve(providerResult) + await expect(second.result).resolves.toEqual(providerResult) + await expect(first.result).resolves.toMatchObject({ status: 'canceled' }) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('cancels once on last release and waits for local provider settlement', async () => { + const { cancelProviderRequest, pending, registry, starts } = createHarness() + const input = directInput() + const lease = registry.acquire('repo-a:ssh:ssh-a', input) + + expect(lease.release('invalidated')).toBe('cancel-started') + expect(lease.release('stopped')).toBe('already-settled') + + await expect(lease.result).resolves.toMatchObject({ + providerRequestId: lease.providerRequestId, + status: 'canceled' + }) + expect(cancelProviderRequest).toHaveBeenCalledTimes(1) + expect(cancelProviderRequest).toHaveBeenCalledWith(starts[0], 'invalidated') + expect(cancelProviderRequest.mock.calls[0]?.[0]).not.toHaveProperty('waiterLeaseId') + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 0 + }) + + pending[0].resolve(completeResult(starts[0])) + await pending[0].promise + await Promise.resolve() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('reports no cancellation start when the invocation throws before send', async () => { + const pending = controlledPromise() + let sends = 0 + const invokeCancellation = vi.fn(() => { + throw new Error('before send') + }) + const startProviderRequest = vi.fn(() => pending.promise) + const registry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest, + cancelProviderRequest: () => { + invokeCancellation() + sends++ + } + }) + const lease = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(lease.release('invalidated')).toBe('cancel-failed') + expect(invokeCancellation).toHaveBeenCalledOnce() + expect(sends).toBe(0) + await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 1, + waiterLeases: 0 + }) + const replacement = registry.acquire('repo-a:ssh:ssh-a', directInput()) + expect(replacement.providerRequestId).not.toBe(lease.providerRequestId) + expect(startProviderRequest).toHaveBeenCalledTimes(2) + + pending.resolve({ + providerRequestId: lease.providerRequestId, + executionHostId: 'ssh:ssh-a', + status: 'canceled' + }) + await pending.promise + await Promise.resolve() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('keeps asynchronous cancellation fire-and-forget', async () => { + const pending = controlledPromise() + const registry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: () => pending.promise, + cancelProviderRequest: () => Promise.reject(new Error('async cancellation failure')) + }) + const lease = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + expect(lease.release('invalidated')).toBe('cancel-started') + await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) + await Promise.resolve() + + pending.resolve({ + providerRequestId: lease.providerRequestId, + executionHostId: 'ssh:ssh-a', + status: 'canceled' + }) + await pending.promise + await Promise.resolve() + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) + + it('does not join incompatible full-authority inputs under one public key', () => { + const { registry, starts } = createHarness() + + const first = registry.acquire('repo-a:ssh:ssh-a', directInput(authority('epoch-a', 1))) + const second = registry.acquire('repo-a:ssh:ssh-a', directInput(authority('epoch-b', 1))) + const third = registry.acquire('repo-a:ssh:ssh-a', directInput(authority('epoch-a', 2))) + + expect(starts).toHaveLength(3) + expect( + new Set([first.providerRequestId, second.providerRequestId, third.providerRequestId]).size + ).toBe(3) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 3, + waiterLeases: 3 + }) + }) + + it('removes settled invocations so a later acquire starts fresh', async () => { + const { pending, registry, starts } = createHarness() + const first = registry.acquire('repo-a:ssh:ssh-a', directInput()) + + const firstResult = completeResult(starts[0]) + pending[0].resolve(firstResult) + await expect(first.result).resolves.toEqual(firstResult) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + + const second = registry.acquire('repo-a:ssh:ssh-a', directInput()) + expect(starts).toHaveLength(2) + expect(second.providerRequestId).not.toBe(first.providerRequestId) + expect(second.waiterLeaseId).not.toBe(first.waiterLeaseId) + }) + + it('cleans rejected provider invocations without retaining waiters', async () => { + const { pending, registry } = createHarness() + const lease = registry.acquire('repo-a:ssh:ssh-a', directInput()) + const error = new Error('provider failed') + + pending[0].reject(error) + await expect(lease.result).rejects.toBe(error) + expect(registry.getActiveCounts()).toEqual({ + providerInvocations: 0, + waiterLeases: 0 + }) + }) +}) diff --git a/src/renderer/src/store/slices/detected-worktree-refresh-leases.ts b/src/renderer/src/store/slices/detected-worktree-refresh-leases.ts new file mode 100644 index 00000000000..44c4f43c9cb --- /dev/null +++ b/src/renderer/src/store/slices/detected-worktree-refresh-leases.ts @@ -0,0 +1,265 @@ +import type { + DirectSshDetectedWorktreeRequest, + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs, + LocalDetectedWorktreeRequest, + ProviderRequestId +} from '../../../../shared/detected-worktree-provider-contract' + +export type WaiterLeaseId = string & { + readonly __waiterLeaseId: unique symbol +} + +export type DetectedWorktreeRefreshReleaseReason = 'superseded' | 'invalidated' | 'stopped' + +export type DetectedWorktreeRefreshReleaseOutcome = + | 'retained' + | 'cancel-started' + | 'cancel-failed' + | 'already-settled' + +export type DetectedWorktreeRefreshLease = { + waiterLeaseId: WaiterLeaseId + providerRequestId: ProviderRequestId + result: Promise + release(reason: DetectedWorktreeRefreshReleaseReason): DetectedWorktreeRefreshReleaseOutcome +} + +export type DetectedWorktreeRefreshProviderInput = + | Omit + | Omit + +export type DetectedWorktreeRefreshLeaseRegistryOptions = { + startProviderRequest( + request: ListDetectedWorktreesArgs + ): Promise + cancelProviderRequest( + request: ListDetectedWorktreesArgs, + reason: DetectedWorktreeRefreshReleaseReason + ): void | Promise +} + +type LeaseWaiter = { + settled: boolean + resolve(result: HostQualifiedDetectedWorktreeResult): void + reject(error: unknown): void +} + +type ProviderInvocation = { + publicKey: string + request: ListDetectedWorktreesArgs + waiters: Map + settled: boolean + cancelAttempted: boolean +} + +export type DetectedWorktreeRefreshLeaseRegistry = { + acquire( + publicKey: string, + input: DetectedWorktreeRefreshProviderInput + ): DetectedWorktreeRefreshLease + getActiveCounts(): { providerInvocations: number; waiterLeases: number } +} + +let providerRequestSequence = 0 +let waiterLeaseSequence = 0 + +function mintProviderRequestId(): ProviderRequestId { + providerRequestSequence = nextIdentitySequence(providerRequestSequence) + return `detected-worktree-provider-${providerRequestSequence}` as ProviderRequestId +} + +function mintWaiterLeaseId(): WaiterLeaseId { + waiterLeaseSequence = nextIdentitySequence(waiterLeaseSequence) + return `detected-worktree-waiter-${waiterLeaseSequence}` as WaiterLeaseId +} + +function nextIdentitySequence(current: number): number { + if (current >= Number.MAX_SAFE_INTEGER) { + throw new Error('Detected worktree refresh identity sequence exhausted') + } + return current + 1 +} + +function requestsAreCompatible( + request: ListDetectedWorktreesArgs, + input: DetectedWorktreeRefreshProviderInput +): boolean { + if (request.repoId !== input.repoId || request.executionHostId !== input.executionHostId) { + return false + } + const requestAuthority = 'expectedAuthority' in request ? request.expectedAuthority : undefined + const inputAuthority = 'expectedAuthority' in input ? input.expectedAuthority : undefined + if (!requestAuthority || !inputAuthority) { + return requestAuthority === inputAuthority + } + return ( + requestAuthority.targetId === inputAuthority.targetId && + requestAuthority.providerEpoch === inputAuthority.providerEpoch && + requestAuthority.connectionGeneration === inputAuthority.connectionGeneration + ) +} + +function canceledResult(request: ListDetectedWorktreesArgs): HostQualifiedDetectedWorktreeResult { + return { + providerRequestId: request.providerRequestId, + executionHostId: request.executionHostId, + status: 'canceled' + } +} + +function providerRequestWithId( + input: DetectedWorktreeRefreshProviderInput, + providerRequestId: ProviderRequestId +): ListDetectedWorktreesArgs { + if ('expectedAuthority' in input) { + return { + ...input, + expectedAuthority: { ...input.expectedAuthority }, + providerRequestId + } + } + return { ...input, providerRequestId } +} + +export function createDetectedWorktreeRefreshLeaseRegistry( + options: DetectedWorktreeRefreshLeaseRegistryOptions +): DetectedWorktreeRefreshLeaseRegistry { + const invocationsByPublicKey = new Map>() + + const removeInvocation = (invocation: ProviderInvocation): void => { + const bucket = invocationsByPublicKey.get(invocation.publicKey) + if (!bucket) { + return + } + bucket.delete(invocation) + if (bucket.size === 0) { + invocationsByPublicKey.delete(invocation.publicKey) + } + } + + const settleProviderInvocation = ( + invocation: ProviderInvocation, + settlement: + | { status: 'fulfilled'; result: HostQualifiedDetectedWorktreeResult } + | { status: 'rejected'; error: unknown } + ): void => { + if (invocation.settled) { + return + } + invocation.settled = true + removeInvocation(invocation) + for (const waiter of invocation.waiters.values()) { + if (waiter.settled) { + continue + } + waiter.settled = true + if (settlement.status === 'fulfilled') { + waiter.resolve(settlement.result) + } else { + waiter.reject(settlement.error) + } + } + invocation.waiters.clear() + } + + const startInvocation = ( + publicKey: string, + input: DetectedWorktreeRefreshProviderInput + ): ProviderInvocation => { + const request = providerRequestWithId(input, mintProviderRequestId()) + const invocation: ProviderInvocation = { + publicKey, + request, + waiters: new Map(), + settled: false, + cancelAttempted: false + } + const bucket = invocationsByPublicKey.get(publicKey) ?? new Set() + bucket.add(invocation) + invocationsByPublicKey.set(publicKey, bucket) + + let result: Promise + try { + result = options.startProviderRequest(request) + } catch (error) { + result = Promise.reject(error) + } + void result.then( + (providerResult) => + settleProviderInvocation(invocation, { + status: 'fulfilled', + result: providerResult + }), + (error) => settleProviderInvocation(invocation, { status: 'rejected', error }) + ) + return invocation + } + + const acquire = ( + publicKey: string, + input: DetectedWorktreeRefreshProviderInput + ): DetectedWorktreeRefreshLease => { + const invocation = + [...(invocationsByPublicKey.get(publicKey) ?? [])].find( + (candidate) => + !candidate.settled && + !candidate.cancelAttempted && + requestsAreCompatible(candidate.request, input) + ) ?? startInvocation(publicKey, input) + const waiterLeaseId = mintWaiterLeaseId() + let resolveResult!: (result: HostQualifiedDetectedWorktreeResult) => void + let rejectResult!: (error: unknown) => void + const result = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }) + const waiter: LeaseWaiter = { + settled: false, + resolve: resolveResult, + reject: rejectResult + } + invocation.waiters.set(waiterLeaseId, waiter) + + return { + waiterLeaseId, + providerRequestId: invocation.request.providerRequestId, + result, + release: (reason) => { + if (waiter.settled) { + return 'already-settled' + } + waiter.settled = true + invocation.waiters.delete(waiterLeaseId) + waiter.resolve(canceledResult(invocation.request)) + if (invocation.settled || invocation.waiters.size > 0 || invocation.cancelAttempted) { + return 'retained' + } + invocation.cancelAttempted = true + let cancellation: void | Promise + try { + cancellation = options.cancelProviderRequest(invocation.request, reason) + } catch { + return 'cancel-failed' + } + void Promise.resolve(cancellation).catch(() => undefined) + return 'cancel-started' + } + } + } + + return { + acquire, + getActiveCounts: () => { + let providerInvocations = 0 + let waiterLeases = 0 + for (const bucket of invocationsByPublicKey.values()) { + providerInvocations += bucket.size + for (const invocation of bucket) { + waiterLeases += invocation.waiters.size + } + } + return { providerInvocations, waiterLeases } + } + } +} diff --git a/src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts b/src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts new file mode 100644 index 00000000000..a40e11f2dcd --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' +import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +const WORKTREE_ID = 'repo1::/path/wt1' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_TAB_ID = 'tab-target' +const PRIMARY_PTY_ID = 'ssh:target-a@@pty-primary' +const SIBLING_PTY_ID = 'ssh:target-a@@pty-sibling' +const SURVIVOR_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const DETACHED_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 +} + +function createSplitDetachStore() { + const store = createTestStore() + seedStore(store, { + repos: [ + { + id: 'repo1', + path: '/path', + displayName: 'repo1', + badgeColor: '#000', + addedAt: 1, + connectionId: authority.targetId, + executionHostId: 'ssh:target-a' + } + ], + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repo1', + path: '/path/wt1', + hostId: 'ssh:target-a' + }) + ] + }, + tabsByWorktree: { + [WORKTREE_ID]: [ + makeTab({ id: SOURCE_TAB_ID, worktreeId: WORKTREE_ID, ptyId: PRIMARY_PTY_ID }), + makeTab({ id: TARGET_TAB_ID, worktreeId: WORKTREE_ID, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [SOURCE_TAB_ID]: [PRIMARY_PTY_ID, SIBLING_PTY_ID], + [TARGET_TAB_ID]: [] + }, + directSshLivePtyBindingByTabId: { + [SOURCE_TAB_ID]: { + attemptId: 'split-detach' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: PRIMARY_PTY_ID + } + }, + directSshPaneRetryHistoryByTabId: { + [SOURCE_TAB_ID]: { authority, attemptedAt: [1] } + }, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + return store +} + +describe('direct SSH split-pane detach ledger', () => { + it.each([ + { + label: 'primary', + detachedPtyId: PRIMARY_PTY_ID, + survivorPtyId: SIBLING_PTY_ID + }, + { + label: 'non-primary sibling', + detachedPtyId: SIBLING_PTY_ID, + survivorPtyId: PRIMARY_PTY_ID + } + ])( + 'preserves exact authority on both sides of a $label detach', + ({ detachedPtyId, survivorPtyId }) => { + const store = createSplitDetachStore() + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SURVIVOR_LEAF_ID]: survivorPtyId } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId: 'split-detach', + ptyId: survivorPtyId + }) + expect(store.getState().directSshLivePtyBindingByTabId[TARGET_TAB_ID]).toMatchObject({ + attemptId: 'split-detach', + ptyId: detachedPtyId + }) + expect(store.getState().directSshPaneRetryHistoryByTabId[SOURCE_TAB_ID]).toEqual({ + authority, + attemptedAt: [1] + }) + expect(store.getState().directSshPaneRetryHistoryByTabId[TARGET_TAB_ID]).toEqual({ + authority, + attemptedAt: [1] + }) + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(authority)).toBe(0) + expect(store.getState().retryDirectSshTargetPanes(authority, 2)).toBe(0) + } + ) + + it('preserves source continuation when its sibling has not bound yet', () => { + const store = createSplitDetachStore() + const attemptId = 'split-detach' as DirectSshPaneRetryAttemptId + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => + tab.id === SOURCE_TAB_ID ? { ...tab, pendingActivationSpawn: true } : tab + ) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [SOURCE_TAB_ID]: [PRIMARY_PTY_ID] + } + })) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId: PRIMARY_PTY_ID, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0]).toMatchObject({ + ptyId: null, + pendingActivationSpawn: true + }) + + store.getState().updateTabPtyId(SOURCE_TAB_ID, SIBLING_PTY_ID, undefined, attemptId) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId, + ptyId: SIBLING_PTY_ID + }) + expect(store.getState().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual([SIBLING_PTY_ID]) + }) + + it('projects an empty continuation gap to both unbound detach sides', () => { + const store = createSplitDetachStore() + const attemptId = 'split-detach' as DirectSshPaneRetryAttemptId + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: null, + pendingActivationSpawn: true + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [SOURCE_TAB_ID]: [] + } + })) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId: null, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshLivePtyBindingByTabId[SOURCE_TAB_ID]).toMatchObject({ + attemptId + }) + expect(store.getState().directSshLivePtyBindingByTabId[TARGET_TAB_ID]).toMatchObject({ + attemptId + }) + + store.getState().updateTabPtyId(SOURCE_TAB_ID, SIBLING_PTY_ID, undefined, attemptId) + store.getState().updateTabPtyId(TARGET_TAB_ID, PRIMARY_PTY_ID, undefined, attemptId) + + expect(store.getState().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual([SIBLING_PTY_ID]) + expect(store.getState().ptyIdsByTabId[TARGET_TAB_ID]).toEqual([PRIMARY_PTY_ID]) + }) + + it('preserves a pending-only lease when both detach sides are still unbound', () => { + const store = createSplitDetachStore() + const attemptId = 'split-pending-only' as DirectSshPaneRetryAttemptId + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: null, + pendingActivationSpawn: true + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [SOURCE_TAB_ID]: [] + }, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryByTabId: { + [SOURCE_TAB_ID]: { + attemptId, + authority, + tabGeneration: 0, + startedAt: 1 + } + } + })) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: DETACHED_LEAF_ID, + detachedPtyId: null, + sourceLayout: { + root: { type: 'leaf', leafId: SURVIVOR_LEAF_ID }, + activeLeafId: SURVIVOR_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: TARGET_TAB_ID + }) + + expect(store.getState().directSshPaneRetryByTabId).toMatchObject({ + [SOURCE_TAB_ID]: { attemptId, authority, tabGeneration: 0 }, + [TARGET_TAB_ID]: { attemptId, authority, tabGeneration: 0 } + }) + + store.getState().updateTabPtyId(SOURCE_TAB_ID, SIBLING_PTY_ID, undefined, attemptId) + store.getState().updateTabPtyId(TARGET_TAB_ID, PRIMARY_PTY_ID, undefined, attemptId) + + expect(store.getState().ptyIdsByTabId[SOURCE_TAB_ID]).toEqual([SIBLING_PTY_ID]) + expect(store.getState().ptyIdsByTabId[TARGET_TAB_ID]).toEqual([PRIMARY_PTY_ID]) + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts b/src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts new file mode 100644 index 00000000000..51730cf70a8 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts @@ -0,0 +1,164 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { shouldRetryPaneSpawnOnSshReconnect } from '../../hooks/ssh-reconnect-pane-retry' +import { + directSshAuthoritiesEqual, + liveBindingMatches, + pruneObsoleteAuthorityState +} from './direct-ssh-terminal-authority-ledger' +import { invalidateStaleDirectSshTerminalBindings } from './direct-ssh-terminal-recovery' +import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import type { + DirectSshPaneRetryAttemptId, + DirectSshTerminalBindingState, + DirectSshTerminalRetryResult +} from './direct-ssh-terminal-recovery-types' + +const AUTOMATIC_RETRY_LIMIT = 2 + +type DirectSshTerminalRetryOptions = { + qualifiedTabIds?: ReadonlySet +} + +function createAttemptId( + authority: DirectSshAuthority, + tabId: string, + tabGeneration: number, + now: number +): DirectSshPaneRetryAttemptId { + return JSON.stringify([ + authority.targetId, + authority.providerEpoch, + authority.connectionGeneration, + tabId, + tabGeneration, + now + ]) as DirectSshPaneRetryAttemptId +} + +export function retryDirectSshTerminalPanes( + state: DirectSshTerminalBindingState & { + deferredSshSessionIdsByTabId: Record + terminalLayoutsByTabId?: Record + }, + terminalWorkspaceKeys: ReadonlySet, + authority: DirectSshAuthority, + now: number, + options: DirectSshTerminalRetryOptions = {} +): DirectSshTerminalRetryResult { + const authorityState = pruneObsoleteAuthorityState(state, authority) + const invalidated = invalidateStaleDirectSshTerminalBindings( + { ...state, ...authorityState }, + terminalWorkspaceKeys, + authority, + options.qualifiedTabIds + ) + const working = { ...state, ...authorityState, ...invalidated.patch } + let tabsByWorktree = working.tabsByWorktree + let pending = working.directSshPaneRetryByTabId + let history = working.directSshPaneRetryHistoryByTabId + let retriedCount = 0 + + for (const workspaceKey of terminalWorkspaceKeys) { + const tabs = working.tabsByWorktree[workspaceKey] ?? [] + let nextTabs = tabs + for (const [index, tab] of tabs.entries()) { + if (options.qualifiedTabIds && !options.qualifiedTabIds.has(tab.id)) { + continue + } + const currentPending = pending[tab.id] + if ( + currentPending && + directSshAuthoritiesEqual(currentPending.authority, authority) && + currentPending.tabGeneration === (tab.generation ?? 0) + ) { + continue + } + if ( + liveBindingMatches(tab, working.directSshLivePtyBindingByTabId[tab.id], authority) || + !shouldRetryPaneSpawnOnSshReconnect({ + targetId: authority.targetId, + tabPtyId: tab.ptyId, + deferredSessionId: state.deferredSshSessionIdsByTabId[tab.id] + }) + ) { + continue + } + const previousHistory = history[tab.id] + const sameAuthorityAttempts = + previousHistory && directSshAuthoritiesEqual(previousHistory.authority, authority) + ? previousHistory.attemptedAt + : [] + // Why: a 31s PTY timeout outlives the old rolling window; authority rotation is the reset boundary for this automatic chain. + const recentAttempts = sameAuthorityAttempts + if (recentAttempts.length >= AUTOMATIC_RETRY_LIMIT) { + continue + } + const tabGeneration = (tab.generation ?? 0) + 1 + if (nextTabs === tabs) { + nextTabs = [...tabs] + } + nextTabs[index] = { + ...tab, + generation: tabGeneration, + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + state.terminalLayoutsByTabId?.[tab.id] + ) + } + if (pending === working.directSshPaneRetryByTabId) { + pending = { ...working.directSshPaneRetryByTabId } + } + pending[tab.id] = { + attemptId: createAttemptId(authority, tab.id, tabGeneration, now), + authority, + tabGeneration, + startedAt: now + } + if (history === working.directSshPaneRetryHistoryByTabId) { + history = { ...working.directSshPaneRetryHistoryByTabId } + } + history[tab.id] = { authority, attemptedAt: [...recentAttempts, now] } + retriedCount += 1 + } + if (nextTabs !== tabs) { + if (tabsByWorktree === working.tabsByWorktree) { + tabsByWorktree = { ...working.tabsByWorktree } + } + tabsByWorktree[workspaceKey] = nextTabs + } + } + + const recoveryChanged = + Object.keys(authorityState).some( + (key) => + authorityState[key as keyof typeof authorityState] !== + state[key as keyof typeof authorityState] + ) || invalidated.patch != null + if (retriedCount === 0 && !recoveryChanged) { + return { retriedCount, patch: null } + } + return { + retriedCount, + patch: { + ...authorityState, + ...invalidated.patch, + tabsByWorktree, + directSshPaneRetryByTabId: pending, + directSshPaneRetryHistoryByTabId: history + } + } +} + +export function retrySettledDirectSshTerminalPane( + state: DirectSshTerminalBindingState & { + deferredSshSessionIdsByTabId: Record + }, + terminalWorkspaceKeys: ReadonlySet, + authority: DirectSshAuthority, + tabId: string, + now: number +): DirectSshTerminalRetryResult { + return retryDirectSshTerminalPanes(state, terminalWorkspaceKeys, authority, now, { + qualifiedTabIds: new Set([tabId]) + }) +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-authority-ledger.ts b/src/renderer/src/store/slices/direct-ssh-terminal-authority-ledger.ts new file mode 100644 index 00000000000..b0e5d5731c1 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-authority-ledger.ts @@ -0,0 +1,252 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' +import type { + DirectSshLivePtyBinding, + DirectSshPaneRetryResult, + DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery-types' + +export function directSshAuthoritiesEqual( + left: DirectSshAuthority, + right: DirectSshAuthority +): boolean { + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +export function withoutTabIds( + source: Record, + tabIds: ReadonlySet +): Record { + let next = source + for (const tabId of tabIds) { + if (!(tabId in next)) { + continue + } + if (next === source) { + next = { ...source } + } + delete next[tabId] + } + return next +} + +export function pruneObsoleteAuthorityState( + state: DirectSshTerminalBindingState, + authority: DirectSshAuthority +): Pick< + DirectSshTerminalBindingState, + | 'directSshPaneRetryByTabId' + | 'directSshLivePtyBindingByTabId' + | 'directSshPaneRetryHistoryByTabId' +> { + const prune = ( + source: Record + ): Record => { + const obsoleteIds = Object.entries(source) + .filter( + ([, value]) => + value.authority.targetId === authority.targetId && + !directSshAuthoritiesEqual(value.authority, authority) + ) + .map(([tabId]) => tabId) + return withoutTabIds(source, new Set(obsoleteIds)) + } + return { + directSshPaneRetryByTabId: prune(state.directSshPaneRetryByTabId), + directSshLivePtyBindingByTabId: prune(state.directSshLivePtyBindingByTabId), + directSshPaneRetryHistoryByTabId: prune(state.directSshPaneRetryHistoryByTabId) + } +} + +export function liveBindingMatches( + tab: TerminalTab, + binding: DirectSshLivePtyBinding | undefined, + authority: DirectSshAuthority +): boolean { + return Boolean( + binding && + directSshAuthoritiesEqual(binding.authority, authority) && + binding.tabGeneration === (tab.generation ?? 0) && + (binding.ptyId === tab.ptyId || (tab.ptyId == null && Boolean(tab.pendingActivationSpawn))) + ) +} + +export function settleDirectSshPaneRetryState( + state: DirectSshTerminalBindingState, + result: DirectSshPaneRetryResult +): Partial | null { + const pending = state.directSshPaneRetryByTabId[result.tabId] + const live = state.directSshLivePtyBindingByTabId[result.tabId] + const pendingMatches = Boolean( + pending && + pending.attemptId === result.attemptId && + directSshAuthoritiesEqual(pending.authority, result.authority) && + pending.tabGeneration === result.tabGeneration + ) + const liveMatches = Boolean( + live && + live.attemptId === result.attemptId && + directSshAuthoritiesEqual(live.authority, result.authority) && + live.tabGeneration === result.tabGeneration + ) + if (!pendingMatches && !liveMatches) { + return null + } + const tabIds = new Set([result.tabId]) + const nextPending = pendingMatches + ? withoutTabIds(state.directSshPaneRetryByTabId, tabIds) + : state.directSshPaneRetryByTabId + if (result.status !== 'success') { + return { + directSshPaneRetryByTabId: nextPending, + directSshLivePtyBindingByTabId: liveMatches + ? withoutTabIds(state.directSshLivePtyBindingByTabId, tabIds) + : state.directSshLivePtyBindingByTabId + } + } + const tab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === result.tabId) + if ( + !tab || + (tab.generation ?? 0) !== result.tabGeneration || + !tab.ptyId || + !(state.ptyIdsByTabId[result.tabId] ?? []).includes(result.ptyId) + ) { + return null + } + return { + directSshPaneRetryByTabId: nextPending, + directSshLivePtyBindingByTabId: { + ...state.directSshLivePtyBindingByTabId, + [result.tabId]: { + attemptId: result.attemptId, + authority: result.authority, + tabGeneration: result.tabGeneration, + ptyId: liveMatches && live ? live.ptyId : tab.ptyId + } + } + } +} + +export function transferDirectSshPaneDetachLedger( + state: DirectSshTerminalBindingState, + args: { + detachedPtyId: string | null + sourcePtyId: string | null + sourceTabId: string + targetTabId: string + isAuthorityCurrent: (authority: DirectSshAuthority) => boolean + } +): Pick< + DirectSshTerminalBindingState, + | 'directSshPaneRetryByTabId' + | 'directSshLivePtyBindingByTabId' + | 'directSshPaneRetryHistoryByTabId' +> { + const tabIds = new Set([args.sourceTabId, args.targetTabId]) + let directSshPaneRetryByTabId = withoutTabIds(state.directSshPaneRetryByTabId, tabIds) + let directSshLivePtyBindingByTabId = withoutTabIds(state.directSshLivePtyBindingByTabId, tabIds) + let directSshPaneRetryHistoryByTabId = withoutTabIds( + state.directSshPaneRetryHistoryByTabId, + tabIds + ) + const tabs = Object.values(state.tabsByWorktree).flat() + const sourceTab = tabs.find((tab) => tab.id === args.sourceTabId) + const targetTab = tabs.find((tab) => tab.id === args.targetTabId) + const live = state.directSshLivePtyBindingByTabId[args.sourceTabId] + const pending = state.directSshPaneRetryByTabId[args.sourceTabId] + const sourceHasPendingContinuation = Boolean( + sourceTab && !args.sourcePtyId && sourceTab.pendingActivationSpawn + ) + const targetHasPendingContinuation = Boolean( + targetTab && !args.detachedPtyId && targetTab.pendingActivationSpawn + ) + const hasEmptyPendingContinuation = Boolean( + pending && + sourceHasPendingContinuation && + targetHasPendingContinuation && + pending.tabGeneration === (sourceTab?.generation ?? 0) && + args.isAuthorityCurrent(pending.authority) + ) + const liveLease = + live && sourceTab && liveBindingMatches(sourceTab, live, live.authority) ? live : null + const pendingLease = + pending && + sourceTab && + pending.tabGeneration === (sourceTab.generation ?? 0) && + ([args.detachedPtyId, args.sourcePtyId].some( + (ptyId) => parseAppSshPtyId(ptyId ?? '')?.connectionId === pending.authority.targetId + ) || + hasEmptyPendingContinuation) + ? pending + : null + const sourceLease = liveLease ?? pendingLease + const authority = sourceLease?.authority + if ( + sourceLease && + authority && + targetTab && + (args.detachedPtyId || targetHasPendingContinuation) && + args.isAuthorityCurrent(authority) + ) { + const nextLiveBindings = { ...directSshLivePtyBindingByTabId } + if ( + sourceTab && + ((args.sourcePtyId && + parseAppSshPtyId(args.sourcePtyId)?.connectionId === authority.targetId) || + sourceHasPendingContinuation) + ) { + if (liveLease) { + nextLiveBindings[args.sourceTabId] = { + attemptId: sourceLease.attemptId, + authority, + tabGeneration: sourceTab.generation ?? 0, + ptyId: args.sourcePtyId ?? liveLease.ptyId + } + } else if (pendingLease) { + directSshPaneRetryByTabId = { + ...directSshPaneRetryByTabId, + [args.sourceTabId]: pendingLease + } + } + } + if (args.detachedPtyId || liveLease) { + nextLiveBindings[args.targetTabId] = { + attemptId: sourceLease.attemptId, + authority, + tabGeneration: targetTab.generation ?? 0, + ptyId: args.detachedPtyId ?? liveLease!.ptyId + } + } else if (pendingLease) { + directSshPaneRetryByTabId = { + ...directSshPaneRetryByTabId, + [args.targetTabId]: { + ...pendingLease, + tabGeneration: targetTab.generation ?? 0 + } + } + } + directSshLivePtyBindingByTabId = nextLiveBindings + const history = state.directSshPaneRetryHistoryByTabId[args.sourceTabId] + if (history && directSshAuthoritiesEqual(history.authority, authority)) { + directSshPaneRetryHistoryByTabId = { + ...directSshPaneRetryHistoryByTabId, + ...(args.sourcePtyId || sourceHasPendingContinuation + ? { [args.sourceTabId]: history } + : {}), + [args.targetTabId]: history + } + } + } + return { + directSshPaneRetryByTabId, + directSshLivePtyBindingByTabId, + directSshPaneRetryHistoryByTabId + } +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts new file mode 100644 index 00000000000..2784559f201 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts @@ -0,0 +1,64 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { TerminalTab } from '../../../../shared/types' +import type { CodexRestartNotice } from './terminals' + +export type DirectSshPaneRetryAttemptId = string & { + readonly __directSshPaneRetryAttemptId: unique symbol +} + +export type DirectSshPaneRetryAttempt = { + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + startedAt: number +} + +export type DirectSshLivePtyBinding = { + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + ptyId: string +} + +export type DirectSshPaneRetryHistory = { + authority: DirectSshAuthority + attemptedAt: number[] +} + +export type DirectSshPaneRetryResult = + | { + status: 'success' + tabId: string + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + ptyId: string + } + | { + status: 'failed' | 'timed-out' | 'superseded' + tabId: string + attemptId: DirectSshPaneRetryAttemptId + authority: DirectSshAuthority + tabGeneration: number + } + +export type DirectSshTerminalBindingState = { + tabsByWorktree: Record + ptyIdsByTabId: Record + lastKnownRelayPtyIdByTabId: Record + pendingCodexPaneRestartIds: Record + codexRestartNoticeByPtyId: Record + directSshPaneRetryByTabId: Record + directSshLivePtyBindingByTabId: Record + directSshPaneRetryHistoryByTabId: Record +} + +export type DirectSshTerminalBindingClearResult = { + clearedCount: number + patch: Partial | null +} + +export type DirectSshTerminalRetryResult = { + retriedCount: number + patch: Partial | null +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts new file mode 100644 index 00000000000..acc1db0ec72 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { + clearDirectSshTerminalBindings, + type DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery' +import { createTestStore, makeWorktree } from './store-test-helpers' + +function makeTab(id: string, ptyId: string | null, pendingActivationSpawn?: true): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo::/work', + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(pendingActivationSpawn ? { pendingActivationSpawn } : {}) + } +} + +function makeState(): DirectSshTerminalBindingState { + return { + tabsByWorktree: { + 'repo::/ssh-work': [ + makeTab('ssh-live', 'ssh-target@@pty-1', true), + makeTab('ssh-unbound', null, true) + ], + 'folder::ssh-work': [makeTab('folder-live', 'ssh-target@@pty-2')], + 'repo::/other-host': [makeTab('other-live', 'ssh-other@@pty-3', true)] + }, + ptyIdsByTabId: { + 'ssh-live': ['ssh-target@@pty-1', 'ssh-target@@pty-split'], + 'ssh-unbound': [], + 'folder-live': ['ssh-target@@pty-2'], + 'other-live': ['ssh-other@@pty-3'] + }, + lastKnownRelayPtyIdByTabId: { + 'ssh-live': 'ssh-target@@pty-1', + 'folder-live': 'ssh-target@@pty-2', + 'other-live': 'ssh-other@@pty-3' + }, + pendingCodexPaneRestartIds: { + 'ssh-target@@pty-1': true, + 'ssh-target@@pty-split': true, + 'ssh-other@@pty-3': true + }, + codexRestartNoticeByPtyId: { + 'ssh-target@@pty-1': { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + }, + 'ssh-other@@pty-3': { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + } + }, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryHistoryByTabId: {} + } +} + +describe('clearDirectSshTerminalBindings', () => { + it('clears exact Git and folder workspace bindings in one projection', () => { + const state = makeState() + const result = clearDirectSshTerminalBindings( + state, + new Set(['repo::/ssh-work', 'folder::ssh-work']) + ) + + expect(result.clearedCount).toBe(2) + expect(result.patch?.tabsByWorktree?.['repo::/ssh-work']).toEqual([ + expect.objectContaining({ id: 'ssh-live', ptyId: null }), + state.tabsByWorktree['repo::/ssh-work'][1] + ]) + expect(result.patch?.tabsByWorktree?.['repo::/ssh-work'][0]).not.toHaveProperty( + 'pendingActivationSpawn' + ) + expect(result.patch?.tabsByWorktree?.['repo::/ssh-work'][1]).toBe( + state.tabsByWorktree['repo::/ssh-work'][1] + ) + expect(result.patch?.tabsByWorktree?.['folder::ssh-work'][0].ptyId).toBeNull() + expect(result.patch?.ptyIdsByTabId).toMatchObject({ + 'ssh-live': [], + 'folder-live': [], + 'other-live': ['ssh-other@@pty-3'] + }) + }) + + it('preserves relay reattach ids and every other host', () => { + const state = makeState() + const result = clearDirectSshTerminalBindings(state, new Set(['repo::/ssh-work'])) + + expect(result.patch).not.toHaveProperty('lastKnownRelayPtyIdByTabId') + expect(state.lastKnownRelayPtyIdByTabId['ssh-live']).toBe('ssh-target@@pty-1') + expect(result.patch?.tabsByWorktree?.['repo::/other-host']).toBe( + state.tabsByWorktree['repo::/other-host'] + ) + expect(result.patch?.pendingCodexPaneRestartIds).toEqual({ + 'ssh-other@@pty-3': true + }) + expect(result.patch?.codexRestartNoticeByPtyId).toEqual({ + 'ssh-other@@pty-3': { + previousAccountLabel: 'old', + nextAccountLabel: 'new' + } + }) + }) + + it('re-arms reconnectable disconnects without erasing retry history', () => { + const state = makeState() + const authority = { + targetId: 'target', + providerEpoch: 'epoch-1', + connectionGeneration: 1 + } + state.directSshPaneRetryByTabId['ssh-live'] = { + attemptId: 'attempt-1', + authority, + tabGeneration: 1, + startedAt: 10 + } as never + state.directSshLivePtyBindingByTabId['folder-live'] = { + authority, + tabGeneration: 0, + ptyId: 'ssh-target@@pty-2' + } as never + state.directSshPaneRetryHistoryByTabId['ssh-live'] = { + authority, + attemptedAt: [10] + } as never + + const result = clearDirectSshTerminalBindings( + state, + new Set(['repo::/ssh-work', 'folder::ssh-work']) + ) + + expect(result.patch?.directSshPaneRetryByTabId).toEqual({}) + expect(result.patch?.directSshLivePtyBindingByTabId).toEqual({}) + expect(result.patch).not.toHaveProperty('directSshPaneRetryHistoryByTabId') + expect(state.directSshPaneRetryHistoryByTabId['ssh-live']?.attemptedAt).toEqual([10]) + }) + + it('clears an exact target through one Zustand publication without activity changes', () => { + const store = createTestStore() + const worktreeId = 'repo-ssh::/work/demo' + const tab = { ...makeTab('ssh-live', 'ssh-target@@pty-1', true), worktreeId } + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/work/demo', + displayName: 'demo', + badgeColor: '#000', + addedAt: 1, + connectionId: 'target' + } + ], + worktreesByRepo: { + 'repo-ssh': [ + makeWorktree({ + id: worktreeId, + repoId: 'repo-ssh', + path: '/work/demo', + hostId: 'ssh:target' + }) + ] + }, + tabsByWorktree: { [worktreeId]: [tab] }, + ptyIdsByTabId: { [tab.id]: ['ssh-target@@pty-1'] }, + lastKnownRelayPtyIdByTabId: { [tab.id]: 'ssh-target@@pty-1' }, + sortEpoch: 9 + }) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + expect(store.getState().clearDirectSshTargetPtyBindings('target')).toBe(1) + unsubscribe() + + const state = store.getState() + expect(publications).toBe(1) + expect(state.tabsByWorktree[worktreeId][0]).toMatchObject({ ptyId: null }) + expect(state.ptyIdsByTabId[tab.id]).toEqual([]) + expect(state.lastKnownRelayPtyIdByTabId[tab.id]).toBe('ssh-target@@pty-1') + expect(state.sortEpoch).toBe(9) + }) + + it('leaves unbound tabs byte-identical and repeats as a no-op', () => { + const state = makeState() + const first = clearDirectSshTerminalBindings(state, new Set(['repo::/ssh-work'])) + const afterFirst = { ...state, ...first.patch } + const second = clearDirectSshTerminalBindings(afterFirst, new Set(['repo::/ssh-work'])) + + expect(first.patch?.tabsByWorktree?.['repo::/ssh-work'][1]).toBe( + state.tabsByWorktree['repo::/ssh-work'][1] + ) + expect(second).toEqual({ clearedCount: 0, patch: null }) + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts new file mode 100644 index 00000000000..8cc2978379b --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts @@ -0,0 +1,173 @@ +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { TerminalTab } from '../../../../shared/types' +import type { CodexRestartNotice } from './terminals' +import { + directSshAuthoritiesEqual, + liveBindingMatches, + pruneObsoleteAuthorityState, + withoutTabIds +} from './direct-ssh-terminal-authority-ledger' +import type { + DirectSshTerminalBindingClearResult, + DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery-types' +export type { + DirectSshLivePtyBinding, + DirectSshPaneRetryAttempt, + DirectSshPaneRetryAttemptId, + DirectSshPaneRetryHistory, + DirectSshPaneRetryResult, + DirectSshTerminalBindingState +} from './direct-ssh-terminal-recovery-types' + +function clearPtyBinding( + state: DirectSshTerminalBindingState, + tab: TerminalTab, + next: { + ptyIdsByTabId: Record + pendingCodexPaneRestartIds: Record + codexRestartNoticeByPtyId: Record + } +): void { + const clearedPtyIds = new Set([tab.ptyId, ...(state.ptyIdsByTabId[tab.id] ?? [])]) + next.ptyIdsByTabId[tab.id] = [] + for (const ptyId of clearedPtyIds) { + if (!ptyId) { + continue + } + delete next.pendingCodexPaneRestartIds[ptyId] + delete next.codexRestartNoticeByPtyId[ptyId] + } +} + +export function clearDirectSshTerminalBindings( + state: DirectSshTerminalBindingState, + terminalWorkspaceKeys: ReadonlySet, + qualifiedTabIds?: ReadonlySet +): DirectSshTerminalBindingClearResult { + let tabsByWorktree = state.tabsByWorktree + const ptyIdsByTabId = { ...state.ptyIdsByTabId } + const pendingCodexPaneRestartIds = { ...state.pendingCodexPaneRestartIds } + const codexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId } + const scopedTabIds = new Set() + let clearedCount = 0 + + for (const workspaceKey of terminalWorkspaceKeys) { + const tabs = state.tabsByWorktree[workspaceKey] + if (!tabs) { + continue + } + let nextTabs = tabs + for (const [index, tab] of tabs.entries()) { + if (qualifiedTabIds && !qualifiedTabIds.has(tab.id)) { + continue + } + scopedTabIds.add(tab.id) + if (tab.ptyId == null) { + continue + } + const { pendingActivationSpawn: _pendingActivationSpawn, ...tabWithoutActivationSpawn } = tab + void _pendingActivationSpawn + if (nextTabs === tabs) { + nextTabs = [...tabs] + } + nextTabs[index] = { ...tabWithoutActivationSpawn, ptyId: null } + clearPtyBinding(state, tab, { + ptyIdsByTabId, + pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId + }) + clearedCount += 1 + } + if (nextTabs !== tabs) { + if (tabsByWorktree === state.tabsByWorktree) { + tabsByWorktree = { ...state.tabsByWorktree } + } + tabsByWorktree[workspaceKey] = nextTabs + } + } + + const directSshPaneRetryByTabId = withoutTabIds(state.directSshPaneRetryByTabId, scopedTabIds) + const directSshLivePtyBindingByTabId = withoutTabIds( + state.directSshLivePtyBindingByTabId, + scopedTabIds + ) + const recoveryChanged = + directSshPaneRetryByTabId !== state.directSshPaneRetryByTabId || + directSshLivePtyBindingByTabId !== state.directSshLivePtyBindingByTabId + if (clearedCount === 0 && !recoveryChanged) { + return { clearedCount, patch: null } + } + return { + clearedCount, + patch: { + tabsByWorktree, + ptyIdsByTabId, + pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId, + directSshPaneRetryByTabId, + directSshLivePtyBindingByTabId + } + } +} + +export function invalidateStaleDirectSshTerminalBindings( + state: DirectSshTerminalBindingState, + terminalWorkspaceKeys: ReadonlySet, + authority: DirectSshAuthority, + qualifiedTabIds?: ReadonlySet +): DirectSshTerminalBindingClearResult { + const authorityState = pruneObsoleteAuthorityState(state, authority) + const staleTabIds = new Set() + const preservedPendingByTabId: DirectSshTerminalBindingState['directSshPaneRetryByTabId'] = {} + for (const workspaceKey of terminalWorkspaceKeys) { + for (const tab of state.tabsByWorktree[workspaceKey] ?? []) { + if (qualifiedTabIds && !qualifiedTabIds.has(tab.id)) { + continue + } + const liveBinding = authorityState.directSshLivePtyBindingByTabId[tab.id] + const pending = authorityState.directSshPaneRetryByTabId[tab.id] + const hasCurrentLiveBinding = liveBindingMatches(tab, liveBinding, authority) + const hasCurrentPending = + pending != null && + directSshAuthoritiesEqual(pending.authority, authority) && + pending.tabGeneration === (tab.generation ?? 0) + if ( + (tab.ptyId != null && !hasCurrentLiveBinding) || + (liveBinding != null && !hasCurrentLiveBinding) || + (pending != null && !hasCurrentPending) + ) { + staleTabIds.add(tab.id) + if (hasCurrentPending) { + preservedPendingByTabId[tab.id] = pending + } + } + } + } + const cleared = clearDirectSshTerminalBindings( + { ...state, ...authorityState }, + terminalWorkspaceKeys, + staleTabIds + ) + const authorityChanged = Object.keys(authorityState).some( + (key) => + authorityState[key as keyof typeof authorityState] !== + state[key as keyof typeof authorityState] + ) + if (!cleared.patch && !authorityChanged) { + return cleared + } + const preservedPending = + cleared.patch && Object.keys(preservedPendingByTabId).length > 0 + ? { + directSshPaneRetryByTabId: { + ...cleared.patch.directSshPaneRetryByTabId, + ...preservedPendingByTabId + } + } + : {} + return { + clearedCount: cleared.clearedCount, + patch: { ...authorityState, ...cleared.patch, ...preservedPending } + } +} diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts new file mode 100644 index 00000000000..0b82798bac3 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts @@ -0,0 +1,647 @@ +import { describe, expect, it } from 'vitest' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' + +const WORKTREE_ID = 'repo-ssh::/work/demo' +const TAB_ID = 'tab-ssh' + +function authority(epoch = 'epoch-1', generation = 1): DirectSshAuthority { + return { + targetId: 'target', + providerEpoch: epoch as SshProviderEpoch, + connectionGeneration: generation + } +} + +function seedStore(ptyId: string | null = null) { + const store = createTestStore() + const currentAuthority = authority() + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/work/demo', + displayName: 'demo', + badgeColor: '#000', + addedAt: 1, + connectionId: 'target', + executionHostId: 'ssh:target' + } + ], + worktreesByRepo: { + 'repo-ssh': [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repo-ssh', + path: '/work/demo', + hostId: 'ssh:target' + }) + ] + }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID, ptyId })] + }, + ptyIdsByTabId: { [TAB_ID]: ptyId ? [ptyId] : [] }, + lastKnownRelayPtyIdByTabId: ptyId ? { [TAB_ID]: ptyId } : {}, + sshConnectionStates: new Map([ + [ + 'target', + { + targetId: 'target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: currentAuthority.providerEpoch, + connectionGeneration: currentAuthority.connectionGeneration + } + ] + ]) + }) + return store +} + +describe('direct SSH terminal retry ledger', () => { + it('invalidates a non-null binding without current-authority evidence atomically', () => { + const ptyId = 'ssh:target@@pty-old' + const store = seedStore(ptyId) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(authority())).toBe(1) + unsubscribe() + + expect(publications).toBe(1) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBeNull() + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId[TAB_ID]).toBe(ptyId) + }) + + it('preserves a healthy current-authority sibling in the same workspace', () => { + const store = seedStore('ssh:target@@pty-stale') + const sibling = makeTab({ + id: 'tab-healthy', + worktreeId: WORKTREE_ID, + ptyId: 'ssh:target@@pty-healthy' + }) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: [...state.tabsByWorktree[WORKTREE_ID], sibling] + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + 'tab-healthy': ['ssh:target@@pty-healthy'] + }, + directSshLivePtyBindingByTabId: { + 'tab-healthy': { + attemptId: 'healthy-attempt' as DirectSshPaneRetryAttemptId, + authority: authority(), + tabGeneration: 0, + ptyId: 'ssh:target@@pty-healthy' + } + } + })) + + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(authority())).toBe(1) + + const tabs = store.getState().tabsByWorktree[WORKTREE_ID] + expect(tabs.find((tab) => tab.id === TAB_ID)?.ptyId).toBeNull() + expect(tabs.find((tab) => tab.id === 'tab-healthy')).toBe(sibling) + expect(store.getState().ptyIdsByTabId['tab-healthy']).toEqual(['ssh:target@@pty-healthy']) + expect(store.getState().directSshLivePtyBindingByTabId['tab-healthy']).toBeDefined() + }) + + it('keeps one pending attempt and acknowledges success after the live commit', () => { + const store = seedStore() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + unsubscribe() + expect(publications).toBe(1) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + const retried = store.getState().tabsByWorktree[WORKTREE_ID][0] + expect(retried).toMatchObject({ generation: 1, pendingActivationSpawn: true }) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toMatchObject({ + tabGeneration: 1, + startedAt: 1_000 + }) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const pendingBeforeStaleSettlement = store.getState().directSshPaneRetryByTabId + store.getState().settleDirectSshPaneRetry({ + status: 'failed', + tabId: TAB_ID, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration - 1 + }) + expect(store.getState().directSshPaneRetryByTabId).toBe(pendingBeforeStaleSettlement) + + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: attempt.attemptId, + authority: authority('stale-epoch', 0), + tabGeneration: attempt.tabGeneration + }, + 1_001 + ) + expect(store.getState().directSshPaneRetryByTabId).toBe(pendingBeforeStaleSettlement) + + store.getState().settleDirectSshPaneRetry({ + status: 'success', + tabId: TAB_ID, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration, + ptyId: 'ssh:target@@pty-new' + }) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeDefined() + + store + .getState() + .updateTabPtyId( + TAB_ID, + 'ssh:target@@pty-new', + undefined, + 'wrong-attempt' as DirectSshPaneRetryAttemptId + ) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeDefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBeNull() + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + expect(store.getState().lastKnownRelayPtyIdByTabId[TAB_ID]).toBeUndefined() + + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + tabGeneration: 1, + ptyId: 'ssh:target@@pty-new' + }) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_002)).toBe(0) + }) + + it('re-arms one failure and exhausts the authority chain after two attempts', () => { + const store = seedStore() + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_000 + ) + const secondAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + expect(secondAttempt.tabGeneration).toBe(2) + store.getState().settleDirectSshPaneRetry( + { + status: 'timed-out', + tabId: TAB_ID, + attemptId: secondAttempt.attemptId, + authority: secondAttempt.authority, + tabGeneration: secondAttempt.tabGeneration + }, + 3_000 + ) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(secondAttempt) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + expect(store.getState().retryDirectSshTargetPanes(authority(), 30_999)).toBe(0) + expect(store.getState().retryDirectSshTargetPanes(authority(), 31_000)).toBe(0) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + }) + + it('keeps one split-pane attempt valid until every sibling settles', () => { + const store = seedStore() + store.setState({ + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].pendingActivationSpawn).toBe(2) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const firstPtyId = 'ssh:target@@pty-first' + const siblingPtyId = 'ssh:target@@pty-sibling' + + store.getState().updateTabPtyId(TAB_ID, firstPtyId, undefined, firstAttempt.attemptId) + store.getState().updateTabPtyId(TAB_ID, siblingPtyId, undefined, firstAttempt.attemptId) + + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([firstPtyId, siblingPtyId]) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(firstPtyId) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].pendingActivationSpawn).toBeUndefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: firstAttempt.attemptId, + ptyId: firstPtyId + }) + + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_000 + ) + const secondAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + expect(secondAttempt.tabGeneration).toBe(2) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([]) + + const beforeStaleCallback = store.getState() + store + .getState() + .updateTabPtyId(TAB_ID, 'ssh:target@@pty-stale', undefined, firstAttempt.attemptId) + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_001 + ) + + expect(store.getState().tabsByWorktree).toBe(beforeStaleCallback.tabsByWorktree) + expect(store.getState().ptyIdsByTabId).toBe(beforeStaleCallback.ptyIdsByTabId) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(secondAttempt) + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID].attemptedAt).toEqual([ + 1_000, 2_000 + ]) + + const secondFirstPtyId = 'ssh:target@@pty-second-first' + const secondSiblingPtyId = 'ssh:target@@pty-second-sibling' + store.getState().updateTabPtyId(TAB_ID, secondFirstPtyId, undefined, secondAttempt.attemptId) + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: secondAttempt.attemptId, + authority: secondAttempt.authority, + tabGeneration: secondAttempt.tabGeneration + }, + 3_000 + ) + store.getState().updateTabPtyId(TAB_ID, secondSiblingPtyId, undefined, secondAttempt.attemptId) + + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([secondFirstPtyId, secondSiblingPtyId]) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().retryDirectSshTargetPanes(authority(), 3_001)).toBe(0) + }) + + it('promotes a surviving split PTY without revoking its exact retry lease', () => { + const store = seedStore() + store.setState({ activeWorktreeId: null }) + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const firstPtyId = 'ssh:target@@pty-first' + const siblingPtyId = 'ssh:target@@pty-sibling' + store.getState().updateTabPtyId(TAB_ID, firstPtyId, undefined, attempt.attemptId) + store.getState().updateTabPtyId(TAB_ID, siblingPtyId, undefined, attempt.attemptId) + + store.getState().clearTabPtyId(TAB_ID, firstPtyId) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(siblingPtyId) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([siblingPtyId]) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: attempt.attemptId, + ptyId: siblingPtyId + }) + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(0) + }) + + it('keeps exact retry authority through a primary-exit gap before a sibling binds', () => { + const store = seedStore() + store.setState({ + activeWorktreeId: null, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + }) + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const firstPtyId = 'ssh:target@@pty-first' + const siblingPtyId = 'ssh:target@@pty-sibling' + + store.getState().updateTabPtyId(TAB_ID, firstPtyId, undefined, attempt.attemptId) + store.getState().clearTabPtyId(TAB_ID, firstPtyId) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0]).toMatchObject({ + ptyId: null, + pendingActivationSpawn: true + }) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: attempt.attemptId, + ptyId: firstPtyId + }) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + + store.getState().updateTabPtyId(TAB_ID, siblingPtyId, undefined, attempt.attemptId) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].ptyId).toBe(siblingPtyId) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([siblingPtyId]) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toMatchObject({ + attemptId: attempt.attemptId, + ptyId: siblingPtyId + }) + }) + + it('does not turn two thirty-one-second timeouts into an unbounded retry chain', () => { + const store = seedStore() + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().settleDirectSshPaneRetry( + { + status: 'timed-out', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 32_000 + ) + const secondAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + expect(secondAttempt.tabGeneration).toBe(2) + + store.getState().settleDirectSshPaneRetry( + { + status: 'timed-out', + tabId: TAB_ID, + attemptId: secondAttempt.attemptId, + authority: secondAttempt.authority, + tabGeneration: secondAttempt.tabGeneration + }, + 63_000 + ) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(secondAttempt) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + expect(store.getState().retryDirectSshTargetPanes(authority(), 63_000)).toBe(0) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(2) + }) + + it('re-arms only the exact failed tab', () => { + const store = seedStore() + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const firstAttempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + const sibling = makeTab({ + id: 'tab-unrelated', + worktreeId: WORKTREE_ID, + ptyId: null + }) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: [...state.tabsByWorktree[WORKTREE_ID], sibling] + }, + ptyIdsByTabId: { ...state.ptyIdsByTabId, [sibling.id]: [] } + })) + + store.getState().settleDirectSshPaneRetry( + { + status: 'failed', + tabId: TAB_ID, + attemptId: firstAttempt.attemptId, + authority: firstAttempt.authority, + tabGeneration: firstAttempt.tabGeneration + }, + 2_000 + ) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]?.tabGeneration).toBe(2) + expect(store.getState().directSshPaneRetryByTabId[sibling.id]).toBeUndefined() + expect( + store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === sibling.id) + ?.generation ?? 0 + ).toBe(0) + }) + + it('re-arms a pending attempt when its bound SSH PTY exits', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: 'ssh:target@@pty-failed' + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [TAB_ID]: ['ssh:target@@pty-failed'] + } + })) + + store.getState().clearTabPtyId(TAB_ID, 'ssh:target@@pty-failed') + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(1) + }) + + it('clearing a successful binding re-arms it without discarding relay identity', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + + store.getState().clearTabPtyId(TAB_ID) + + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().lastKnownRelayPtyIdByTabId[TAB_ID]).toBe('ssh:target@@pty-new') + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(1) + }) + + it('removes successful evidence when a snapshot overwrites the live binding', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: null + })) + }, + ptyIdsByTabId: { ...state.ptyIdsByTabId, [TAB_ID]: [] } + })) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 2_000)).toBe(1) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeDefined() + }) + + it('keeps snapshot-restored PTY hints eligible for corrective retry', () => { + const store = seedStore() + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: 'ssh:target@@pty-snapshot' + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [TAB_ID]: ['ssh:target@@pty-snapshot'] + } + })) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toMatchObject({ + authority: authority(), + tabGeneration: 1 + }) + }) + + it('clears a snapshot hint without superseding its current pending attempt', () => { + const store = seedStore() + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + const pending = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + ptyId: 'ssh:target@@pty-snapshot' + })) + }, + ptyIdsByTabId: { + ...state.ptyIdsByTabId, + [TAB_ID]: ['ssh:target@@pty-snapshot'] + } + })) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + + expect(store.getState().tabsByWorktree[WORKTREE_ID][0]).toMatchObject({ + generation: 1, + ptyId: null + }) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBe(pending) + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID].attemptedAt).toEqual([1_000]) + }) + + it('rotates obsolete pending, success, and retry history by exact authority', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-new', undefined, attempt.attemptId) + const nextAuthority = authority('epoch-2', 2) + store.setState({ + sshConnectionStates: new Map([ + [ + 'target', + { + targetId: 'target', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: nextAuthority.providerEpoch, + connectionGeneration: nextAuthority.connectionGeneration + } + ] + ]) + }) + + expect(store.getState().retryDirectSshTargetPanes(nextAuthority, 1_001)).toBe(1) + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]?.authority).toEqual(nextAuthority) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID]).toEqual({ + authority: nextAuthority, + attemptedAt: [1_001] + }) + }) + + it('fails closed when the store no longer names the exact authority', () => { + const store = seedStore() + const stale = authority('stale', 0) + const before = store.getState() + + expect(store.getState().retryDirectSshTargetPanes(stale, 1_000)).toBe(0) + expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(stale)).toBe(0) + expect(store.getState().tabsByWorktree).toBe(before.tabsByWorktree) + expect(store.getState().directSshPaneRetryByTabId).toBe(before.directSshPaneRetryByTabId) + }) + + it('retries a stale-catalog tab from retained SSH ownership exactly once', () => { + const store = seedStore() + store.setState({ + repos: [], + worktreesByRepo: {}, + lastKnownRelayPtyIdByTabId: { [TAB_ID]: 'ssh:target@@pty-retained' }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID, ptyId: null })], + local: [makeTab({ id: 'tab-local', worktreeId: 'local', ptyId: null })] + } + }) + + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_000)).toBe(1) + expect(store.getState().retryDirectSshTargetPanes(authority(), 1_001)).toBe(0) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generation).toBe(1) + expect(store.getState().tabsByWorktree.local[0].generation ?? 0).toBe(0) + }) + + it('removes all direct SSH retry ledgers when a tab closes', () => { + const store = seedStore() + store.getState().retryDirectSshTargetPanes(authority(), 1_000) + const attempt = store.getState().directSshPaneRetryByTabId[TAB_ID] + store.getState().updateTabPtyId(TAB_ID, 'ssh:target@@pty-live', undefined, attempt.attemptId) + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeDefined() + store.setState((state) => ({ + directSshPaneRetryByTabId: { + ...state.directSshPaneRetryByTabId, + [TAB_ID]: { + attemptId: 'pending-close' as DirectSshPaneRetryAttemptId, + authority: authority(), + tabGeneration: 1, + startedAt: 2_000 + } + } + })) + + store.getState().closeTab(TAB_ID, { reason: 'pty-exit' }) + + expect(store.getState().directSshPaneRetryByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshLivePtyBindingByTabId[TAB_ID]).toBeUndefined() + expect(store.getState().directSshPaneRetryHistoryByTabId[TAB_ID]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts new file mode 100644 index 00000000000..063a773e1fa --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' +import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' + +function tab(worktreeId: string, ptyId: string): TerminalTab { + return { + id: `tab-${worktreeId}`, + worktreeId, + ptyId, + title: worktreeId, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('resolveDirectSshTerminalWorkspaceKeys', () => { + it('uses a parsed live target PTY when the catalog row is stale', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { targetId: 'target-a', catalogRevision: 1, repos: [] }, + { 'stale-worktree': [tab('stale-worktree', 'ssh:target-a@@pty-1')] } + ) + ).toEqual(new Set(['stale-worktree'])) + }) + + it('uses a retained target PTY after disconnect clears the live binding', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { targetId: 'target-a', catalogRevision: 1, repos: [] }, + { 'stale-worktree': [tab('stale-worktree', '')] }, + { 'tab-stale-worktree': 'ssh:target-a@@pty-1' } + ) + ).toEqual(new Set(['stale-worktree'])) + }) + + it('refuses retained PTY fallback when explicit ownership names another host', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'contradictory-worktree', + repoId: 'repo', + hostId: 'ssh:target-b' + } + ] + } + }, + { 'contradictory-worktree': [tab('contradictory-worktree', '')] }, + { 'tab-contradictory-worktree': 'ssh:target-a@@pty-1' } + ) + ).toEqual(new Set()) + }) + + it('refuses retained PTY fallback when a repo id is owned by two SSH hosts', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [ + { + id: 'shared', + path: '/srv/a/shared', + projectGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:target-a' + }, + { + id: 'shared', + path: '/srv/b/shared', + projectGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + worktreesByRepo: { + shared: [{ id: 'shared::/work', repoId: 'shared' }] + } + }, + { 'shared::/work': [tab('shared::/work', '')] }, + { 'tab-shared::/work': 'ssh:target-a@@pty-retained' } + ) + ).toEqual(new Set()) + }) + + it('refuses a retained target when the current PTY names another SSH host', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { targetId: 'target-a', catalogRevision: 1, repos: [] }, + { + 'stale-worktree': [ + tab('stale-worktree', 'ssh:target-b@@pty-live'), + tab('stale-worktree', '') + ] + }, + { 'tab-stale-worktree': 'ssh:target-a@@pty-retained' } + ) + ).toEqual(new Set()) + }) + + it('refuses parsed PTY fallback when explicit worktree ownership names another host', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'contradictory-worktree', + repoId: 'repo', + hostId: 'ssh:target-b' + } + ] + } + }, + { + 'contradictory-worktree': [tab('contradictory-worktree', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses parsed PTY fallback when explicit host provenance is malformed', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'malformed-worktree', + repoId: 'repo', + hostId: 'ssh:%' + } + ] + } + }, + { + 'malformed-worktree': [tab('malformed-worktree', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses folder PTY fallback when project host provenance is malformed', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + folderWorkspaces: [ + { + id: 'folder-a', + projectGroupId: 'group-a', + folderPath: '/srv/project', + connectionId: 'target-a' + } + ], + projectGroups: [ + { + id: 'group-a', + parentGroupId: null, + connectionId: 'target-a', + executionHostId: 'ssh:%' + } + ] + }, + { + 'folder:folder-a': [tab('folder:folder-a', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses fallback when explicit worktree and repo-derived ownership disagree', () => { + expect( + resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [ + { + id: 'repo', + path: '/srv/repo', + projectGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ], + worktreesByRepo: { + repo: [ + { + id: 'contradictory-worktree', + repoId: 'repo', + hostId: 'ssh:target-a' + } + ] + } + }, + { + 'contradictory-worktree': [tab('contradictory-worktree', 'ssh:target-a@@pty-1')] + } + ) + ).toEqual(new Set()) + }) + + it('refuses runtime-owned and contradictory folder provenance', () => { + const keys = resolveDirectSshTerminalWorkspaceKeys( + { + targetId: 'target-a', + catalogRevision: 1, + repos: [], + worktreesByRepo: { + repo: [ + { + id: 'runtime-worktree', + repoId: 'repo', + hostId: 'ssh:target-a', + runtimeOwnerEnvironmentId: 'runtime-1' + } + ] + }, + folderWorkspaces: [ + { + id: 'folder-b', + projectGroupId: 'group-b', + folderPath: '/srv/project', + connectionId: 'target-b' + } + ], + projectGroups: [ + { + id: 'group-b', + parentGroupId: null, + connectionId: 'target-b', + executionHostId: 'ssh:target-b' + } + ] + }, + { + 'runtime-worktree': [tab('runtime-worktree', 'ssh:target-a@@pty-1')], + 'folder:folder-b': [tab('folder:folder-b', 'ssh:target-a@@pty-2')] + } + ) + + expect(keys).toEqual(new Set()) + }) +}) diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts new file mode 100644 index 00000000000..c970e4fe451 --- /dev/null +++ b/src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.ts @@ -0,0 +1,130 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { + resolveDirectSshTargetScope, + type DirectSshTargetScopeInput +} from '../../lib/direct-ssh-target-scope' + +function isExplicitContradictoryHost( + rawHostId: string | null | undefined, + expectedHostId: string +): boolean { + if (!rawHostId?.trim()) { + return false + } + const parsed = parseExecutionHostId(rawHostId) + return parsed == null || parsed.id !== expectedHostId +} + +function worktreeHasContradictoryOwner( + input: DirectSshTargetScopeInput, + worktreeId: string +): boolean { + const rows = [ + ...Object.values(input.worktreesByRepo ?? {}).flat(), + ...Object.values(input.detectedWorktreesByRepo ?? {}).flatMap((entry) => entry.worktrees) + ].filter((worktree) => worktree.id === worktreeId) + const expectedHostId = toSshExecutionHostId(input.targetId) + for (const row of rows) { + if ( + row.runtimeOwnerEnvironmentId?.trim() || + isExplicitContradictoryHost(row.hostId, expectedHostId) + ) { + return true + } + const repos = input.repos.filter((repo) => repo.id === row.repoId) + if (repos.some((repo) => getRepoExecutionHostId(repo) !== expectedHostId)) { + return true + } + } + const restored = input.restoredRuntimeHostIdByWorkspaceSessionKey + return ( + isExplicitContradictoryHost(restored?.[worktreeId], expectedHostId) || + isExplicitContradictoryHost(restored?.[worktreeWorkspaceKey(worktreeId)], expectedHostId) + ) +} + +function folderHasContradictoryOwner( + input: DirectSshTargetScopeInput, + folderWorkspaceId: string +): boolean { + const expectedHostId = toSshExecutionHostId(input.targetId) + const folders = (input.folderWorkspaces ?? []).filter((folder) => folder.id === folderWorkspaceId) + for (const folder of folders) { + if (folder.connectionId?.trim() && folder.connectionId.trim() !== input.targetId) { + return true + } + const groups = (input.projectGroups ?? []).filter((group) => group.id === folder.projectGroupId) + if ( + groups.some( + (group) => + (group.connectionId?.trim() && group.connectionId.trim() !== input.targetId) || + isExplicitContradictoryHost(group.executionHostId, expectedHostId) + ) + ) { + return true + } + const groupIds = getProjectGroupSubtreeIds(input.projectGroups ?? [], folder.projectGroupId) + const candidateRepos = input.repos.filter( + (repo) => + (repo.projectGroupId != null && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + if (candidateRepos.some((repo) => getRepoExecutionHostId(repo) !== expectedHostId)) { + return true + } + } + return isExplicitContradictoryHost( + input.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folderWorkspaceId)], + expectedHostId + ) +} + +function workspaceHasContradictoryOwner( + input: DirectSshTargetScopeInput, + workspaceKey: string +): boolean { + if (workspaceKey.startsWith('folder:')) { + return folderHasContradictoryOwner(input, workspaceKey.slice('folder:'.length)) + } + return worktreeHasContradictoryOwner(input, workspaceKey) +} + +export function resolveDirectSshTerminalWorkspaceKeys( + input: DirectSshTargetScopeInput, + tabsByWorktree: Readonly>, + lastKnownRelayPtyIdByTabId: Readonly> = {} +): Set { + const keys = new Set(resolveDirectSshTargetScope(input).terminalWorkspaceKeys) + for (const [workspaceKey, tabs] of Object.entries(tabsByWorktree)) { + const liveSshTargets = new Set( + tabs + .map((tab) => parseAppSshPtyId(tab.ptyId ?? '')?.connectionId) + .filter((targetId): targetId is string => Boolean(targetId)) + ) + const retainedSshTargets = new Set( + tabs + .map((tab) => parseAppSshPtyId(lastKnownRelayPtyIdByTabId[tab.id] ?? '')?.connectionId) + .filter((targetId): targetId is string => Boolean(targetId)) + ) + const ptyTargets = liveSshTargets.size > 0 ? liveSshTargets : retainedSshTargets + if ( + keys.has(workspaceKey) || + workspaceHasContradictoryOwner(input, workspaceKey) || + ptyTargets.size !== 1 || + !ptyTargets.has(input.targetId) + ) { + continue + } + keys.add(workspaceKey) + } + return keys +} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 75be706d5f1..cc651ee6cc3 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -39,6 +39,10 @@ import type { } from '../../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' +import { + clampCombinedDiffFileTreeWidth, + COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH +} from '../../../../shared/combined-diff-file-tree-width' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' import { invalidateAutomaticPushTargetUpstreamStatusCache } from '@/components/right-sidebar/push-target-upstream-refresh-cache' @@ -405,6 +409,10 @@ export type EditorSlice = { markdownTocPanelWidth: number setMarkdownTocPanelWidth: (width: number) => void + // Combined diff file tree sizing + combinedDiffFileTreeWidth: number + setCombinedDiffFileTreeWidth: (width: number) => void + // Right sidebar rightSidebarOpen: boolean rightSidebarWidth: number @@ -1434,6 +1442,17 @@ export const createEditorSlice: StateCreator = (s markdownTocPanelWidth: clampMarkdownTocPanelWidth(width, undefined, s.markdownTocPanelWidth) })), + // Combined diff file tree sizing + combinedDiffFileTreeWidth: COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH, + setCombinedDiffFileTreeWidth: (width) => + set((s) => ({ + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + width, + undefined, + s.combinedDiffFileTreeWidth + ) + })), + // Right sidebar rightSidebarOpen: false, rightSidebarWidth: 280, diff --git a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts index b18cd48399d..d785560f393 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts @@ -35,12 +35,15 @@ function createTestStore() { ) } -function seed(store: ReturnType, repo: Record) { +function seed( + store: ReturnType, + repo: Record & { id: string; path: string } +) { store.setState({ settings: { activeRuntimeEnvironmentId: null } as never, repos: [repo], worktreesByRepo: { - [repo.id as string]: [ + [repo.id]: [ { id: 'wt-1', repoId: repo.id, diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts new file mode 100644 index 00000000000..79b1fd4162b --- /dev/null +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -0,0 +1,139 @@ +/** + * Teardown coverage for `nativeChatLaunchDraftByTabId`. + * + * A stranded entry is worse than a plain leak: sync-runtime-graph keeps + * publishing it to mobile as that tab's `launchDraft`. It must evict wherever + * its sibling `nativeChatLaunchPromptByTabId` does — tab close, orphan sweep, + * the bulk worktree purge, and the single removeWorktree teardown. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' + +function draft(tabId: string, text: string) { + return { tabId, agent: 'claude' as const, text, createdAt: 1 } +} + +function seedDrafts(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + }, + nativeChatLaunchDraftByTabId: { + [TAB1]: draft(TAB1, 'https://github.com/o/r/issues/1'), + [TAB2]: draft(TAB2, 'https://github.com/o/r/issues/2') + } + }) +} + +describe('nativeChatLaunchDraftByTabId teardown', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApi.worktrees.remove.mockResolvedValue(undefined) + }) + + it('closeTab drops the closed tab’s draft only', () => { + const store = createTestStore() + seedDrafts(store) + + store.getState().closeTab(TAB1) + + const s = store.getState() + expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) + + it('bulk purgeWorktreeTerminalState drops drafts for the removed worktree only', () => { + const store = createTestStore() + seedDrafts(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + + const s = store.getState() + expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) + + it('single removeWorktree drops drafts for the removed worktree only', async () => { + const store = createTestStore() + seedDrafts(store) + + const result = await store.getState().removeWorktree(WT1) + + expect(result).toEqual({ ok: true }) + const s = store.getState() + expect(s.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(s.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) + + it('persists the adopted flag, idempotently, and clears the whole entry', () => { + // Every consumer test injects these reducers as bare vi.fn()s. `adopted` is + // what stops a manually cleared composer from resurrecting the prefill, so + // assert the real reducers here. + const store = createTestStore() + store.getState().seedNativeChatLaunchDraft(draft(TAB1, 'https://github.com/o/r/issues/1')) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.adopted).toBeUndefined() + + store.getState().markNativeChatLaunchDraftAdopted(TAB1) + const adopted = store.getState().nativeChatLaunchDraftByTabId[TAB1] + expect(adopted).toMatchObject({ tabId: TAB1, adopted: true }) + + store.getState().markNativeChatLaunchDraftAdopted(TAB1) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]).toBe(adopted) + + store.getState().clearNativeChatLaunchDraft(TAB1) + expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false) + }) + + it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => { + const store = createTestStore() + seedDrafts(store) + + const patch = buildOrphanTerminalCleanupPatch(store.getState(), WT1, new Set([TAB1])) + + expect(patch.nativeChatLaunchDraftByTabId[TAB1]).toBeUndefined() + expect(patch.nativeChatLaunchDraftByTabId[TAB2]).toBeDefined() + }) +}) diff --git a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts index f53f3342072..e32f2da3699 100644 --- a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts +++ b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts @@ -1,4 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../../../../shared/detected-worktree-provider-contract' +import type { + DirectSshAuthority, + SshConnectionState, + SshProviderEpoch +} from '../../../../shared/ssh-types' import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import { createTestStore } from './store-test-helpers' @@ -59,6 +68,52 @@ function directSshWorktree(targetId: string, displayName = 'main'): Worktree { } } +function directSshAuthority(targetId: string): DirectSshAuthority { + return { + targetId, + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: 1 + } +} + +function connectedSshState(authority: DirectSshAuthority): SshConnectionState { + return { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } +} + +function qualifiedWorktreeResult( + request: ListDetectedWorktreesArgs, + worktree: Worktree +): HostQualifiedDetectedWorktreeResult { + if (!('expectedAuthority' in request)) { + throw new Error('Expected a direct SSH provider request') + } + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { + kind: 'direct-ssh', + executionHostId: request.executionHostId, + ...request.expectedAuthority + }, + result: { + repoId: request.repoId, + authoritative: true, + source: 'git', + worktrees: [ + { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + } + } +} + const project: Project = { id: projectId, displayName: 'Re-adopted repo', @@ -188,17 +243,23 @@ describe('SSH repo host reconciliation', () => { it('rejects an old-host worktree response that resolves after re-adoption', async () => { const staleWorktree = directSshWorktree('ssh-old', 'stale') - let resolveOldWorktrees!: (value: unknown) => void - const oldWorktrees = new Promise((resolve) => { + const oldAuthority = directSshAuthority('ssh-old') + let providerRequest!: ListDetectedWorktreesArgs + let resolveOldWorktrees!: (value: HostQualifiedDetectedWorktreeResult) => void + const oldWorktrees = new Promise((resolve) => { resolveOldWorktrees = resolve }) - worktreesListDetected.mockReturnValueOnce(oldWorktrees) + worktreesListDetected.mockImplementationOnce((request: ListDetectedWorktreesArgs) => { + providerRequest = request + return oldWorktrees + }) reposList.mockResolvedValue([directSshRepo('ssh-new')]) projectsList.mockResolvedValue([project]) setupsList.mockResolvedValue([directSshSetup('ssh-new')]) const store = createTestStore() store.setState({ repos: [directSshRepo('ssh-old')], + sshConnectionStates: new Map([['ssh-old', connectedSshState(oldAuthority)]]), worktreesByRepo: { [repoId]: [staleWorktree] }, detectedWorktreesByRepo: { [repoId]: { @@ -214,17 +275,20 @@ describe('SSH repo host reconciliation', () => { const staleFetch = store.getState().fetchWorktrees(repoId) await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled()) + expect(worktreesListDetected).toHaveBeenCalledOnce() + expect(providerRequest).toMatchObject({ + repoId, + executionHostId: 'ssh:ssh-old', + expectedAuthority: oldAuthority + }) store.getState().recordSshRepoReadoptions([readoption]) await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - resolveOldWorktrees({ - repoId, - authoritative: true, - source: 'git', - worktrees: [ - { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } - ] + const newAuthority = directSshAuthority('ssh-new') + store.setState({ + sshConnectionStates: new Map([['ssh-new', connectedSshState(newAuthority)]]) }) - await staleFetch + resolveOldWorktrees(qualifiedWorktreeResult(providerRequest, staleWorktree)) + await expect(staleFetch).resolves.toBe(false) expect(store.getState().worktreesByRepo[repoId]).toEqual([ directSshWorktree('ssh-new', 'stale') @@ -241,26 +305,69 @@ describe('SSH repo host reconciliation', () => { it('rejects a worktree response after its final repo owner is removed', async () => { const staleWorktree = directSshWorktree('ssh-old', 'stale') - let resolveOldWorktrees!: (value: unknown) => void - const oldWorktrees = new Promise((resolve) => { + const oldAuthority = directSshAuthority('ssh-old') + let providerRequest!: ListDetectedWorktreesArgs + let resolveOldWorktrees!: (value: HostQualifiedDetectedWorktreeResult) => void + const oldWorktrees = new Promise((resolve) => { resolveOldWorktrees = resolve }) - worktreesListDetected.mockReturnValueOnce(oldWorktrees) + worktreesListDetected.mockImplementationOnce((request: ListDetectedWorktreesArgs) => { + providerRequest = request + return oldWorktrees + }) const store = createTestStore() - store.setState({ repos: [directSshRepo('ssh-old')] }) + store.setState({ + repos: [directSshRepo('ssh-old')], + sshConnectionStates: new Map([['ssh-old', connectedSshState(oldAuthority)]]) + }) const staleFetch = store.getState().fetchWorktrees(repoId) await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalled()) - store.setState({ repos: [], worktreesByRepo: {}, detectedWorktreesByRepo: {} }) - resolveOldWorktrees({ + expect(worktreesListDetected).toHaveBeenCalledOnce() + expect(providerRequest).toMatchObject({ repoId, - authoritative: true, - source: 'git', - worktrees: [ - { ...staleWorktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } - ] + executionHostId: 'ssh:ssh-old', + expectedAuthority: oldAuthority }) - await staleFetch + store.setState({ repos: [], worktreesByRepo: {}, detectedWorktreesByRepo: {} }) + resolveOldWorktrees(qualifiedWorktreeResult(providerRequest, staleWorktree)) + await expect(staleFetch).resolves.toBe(false) + + expect(store.getState().worktreesByRepo[repoId]).toBeUndefined() + expect(store.getState().detectedWorktreesByRepo[repoId]).toBeUndefined() + }) + + it.each([ + ['contradictory', { ...directSshRepo('ssh-old'), connectionId: 'ssh-new' }], + [ + 'malformed', + { ...directSshRepo('ssh-old'), executionHostId: 'ssh:%' as Repo['executionHostId'] } + ], + ['empty explicit host', { ...directSshRepo('ssh-old'), executionHostId: '' }], + ['empty connection', { ...directSshRepo('ssh-old'), connectionId: ' ' }] + ])('rejects a late worktree response when repo owner provenance becomes %s', async (_, owner) => { + const staleWorktree = directSshWorktree('ssh-old', 'stale') + const oldAuthority = directSshAuthority('ssh-old') + let providerRequest!: ListDetectedWorktreesArgs + let resolveOldWorktrees!: (value: HostQualifiedDetectedWorktreeResult) => void + const oldWorktrees = new Promise((resolve) => { + resolveOldWorktrees = resolve + }) + worktreesListDetected.mockImplementationOnce((request: ListDetectedWorktreesArgs) => { + providerRequest = request + return oldWorktrees + }) + const store = createTestStore() + store.setState({ + repos: [directSshRepo('ssh-old')], + sshConnectionStates: new Map([['ssh-old', connectedSshState(oldAuthority)]]) + }) + + const staleFetch = store.getState().fetchWorktrees(repoId) + await vi.waitFor(() => expect(worktreesListDetected).toHaveBeenCalledOnce()) + store.setState({ repos: [owner as Repo] }) + resolveOldWorktrees(qualifiedWorktreeResult(providerRequest, staleWorktree)) + await expect(staleFetch).resolves.toBe(false) expect(store.getState().worktreesByRepo[repoId]).toBeUndefined() expect(store.getState().detectedWorktreesByRepo[repoId]).toBeUndefined() diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts index f620793730a..acc807073cd 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.test.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { SshConnectionState, SshProviderEpoch } from '../../../../shared/ssh-types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { createTestStore } from './store-test-helpers' import { @@ -15,7 +15,14 @@ function connState( targetId: string, status: SshConnectionState['status'] = 'connected' ): SshConnectionState { - return { targetId, status, error: null, reconnectAttempt: 0 } + return { + targetId, + status, + error: null, + reconnectAttempt: 0, + providerEpoch: `${targetId}-provider-epoch` as SshProviderEpoch, + connectionGeneration: 7 + } } function markReachable(store: ReturnType, environmentId: string): void { @@ -48,6 +55,10 @@ describe('runtime-environment-ssh slice', () => { // Environment A's bucket: populated. const bucketA = state.sshStateByEnvironment.get(ENV_A) expect(bucketA?.connectionStates.get('ssh-a')?.status).toBe('connected') + expect(bucketA?.connectionStates.get('ssh-a')).toMatchObject({ + providerEpoch: 'ssh-a-provider-epoch', + connectionGeneration: 7 + }) expect(bucketA?.targetLabels.get('ssh-a')).toBe('a-box') expect(bucketA?.removedTargetLabels.get('ssh-dead')).toBe('old box') expect(bucketA?.targetsHydrated).toBe(true) @@ -80,7 +91,7 @@ describe('runtime-environment-ssh slice', () => { expect(store.getState().sshStateByEnvironment.get(ENV_A)).toBe(bucketBefore) }) - it('publishes an authoritative connection generation change in the owning bucket', () => { + it('publishes a generation-only authority change without dropping the provider epoch', () => { const store = createTestStore() store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', { ...connState('ssh-a'), @@ -95,7 +106,33 @@ describe('runtime-environment-ssh slice', () => { const bucketAfter = store.getState().sshStateByEnvironment.get(ENV_A) expect(bucketAfter).not.toBe(bucketBefore) - expect(bucketAfter?.connectionStates.get('ssh-a')?.connectionGeneration).toBe(2) + expect(bucketAfter?.connectionStates.get('ssh-a')).toMatchObject({ + providerEpoch: 'ssh-a-provider-epoch', + connectionGeneration: 2 + }) + }) + + it('publishes an epoch-only authority change without dropping the connection generation', () => { + const store = createTestStore() + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', { + ...connState('ssh-a'), + providerEpoch: 'provider-a' as never, + connectionGeneration: 1 + }) + const bucketBefore = store.getState().sshStateByEnvironment.get(ENV_A) + + store.getState().setEnvironmentSshConnectionState(ENV_A, 'ssh-a', { + ...connState('ssh-a'), + providerEpoch: 'provider-b' as never, + connectionGeneration: 1 + }) + + const bucketAfter = store.getState().sshStateByEnvironment.get(ENV_A) + expect(bucketAfter).not.toBe(bucketBefore) + expect(bucketAfter?.connectionStates.get('ssh-a')).toMatchObject({ + providerEpoch: 'provider-b', + connectionGeneration: 1 + }) }) it('flips the hydrated flag on the first fetch of an empty target list', () => { diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 2ec22859950..e3a71e58fd6 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -6,8 +6,11 @@ import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' -import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' +import { + createRuntimeStatusSlice, + type RuntimeStatusSlice, + getRuntimeEnvironmentConnectionGeneration +} from './runtime-status' function createSliceStore() { return create()((...a) => ({ diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 3a92d6a7ad2..ba2f5211efc 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -1,6 +1,7 @@ import type { AppState } from '../types' import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { resolveDirectSshTargetScope } from '../../lib/direct-ssh-target-scope' export function sshConnectionStatesEqual( a: SshConnectionState | undefined, @@ -11,6 +12,7 @@ export function sshConnectionStatesEqual( a?.status === b.status && a?.error === b.error && a?.reconnectAttempt === b.reconnectAttempt && + a?.providerEpoch === b.providerEpoch && a?.connectionGeneration === b.connectionGeneration && a?.supportsFolderDownload === b.supportsFolderDownload && a?.remotePlatform === b.remotePlatform @@ -28,15 +30,20 @@ export function sshTargetLabelsEqual( } function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set { - const repoIds = new Set( - state.repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) - ) + const targetWorktreeIds = resolveDirectSshTargetScope({ + targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }).gitWorktreeIds const tabIds = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { - if (!repoIds.has(repoId)) { - continue - } + for (const worktrees of Object.values(state.worktreesByRepo)) { for (const worktree of worktrees) { + if (!targetWorktreeIds.has(worktree.id)) { + continue + } for (const tab of state.tabsByWorktree[worktree.id] ?? []) { tabIds.add(tab.id) } @@ -80,6 +87,19 @@ function omitRemovedSshTargetTabSessions( return { next, removed } } +function omitRemovedSshTargetRecovery( + entries: Record, + targetId: string, + targetTabIds: ReadonlySet +): { next: Record; removed: boolean } { + const next = Object.fromEntries( + Object.entries(entries).filter( + ([tabId, entry]) => !targetTabIds.has(tabId) && entry.authority.targetId !== targetId + ) + ) + return { next, removed: Object.keys(next).length !== Object.keys(entries).length } +} + function clearSshTargetTabPtyState( state: AppState, targetId: string, @@ -160,6 +180,21 @@ export function buildRemovedSshTargetCleanupPatch( // sweep now reads it as liveness, so clear it here too (#9911). const { next: nextPendingReconnect, removed: removedPendingReconnect } = omitRemovedSshTargetTabSessions(state.pendingReconnectPtyIdByTabId, targetId, targetTabIds) + const { next: nextPaneRetries, removed: removedPaneRetries } = omitRemovedSshTargetRecovery( + state.directSshPaneRetryByTabId, + targetId, + targetTabIds + ) + const { next: nextLiveBindings, removed: removedLiveBindings } = omitRemovedSshTargetRecovery( + state.directSshLivePtyBindingByTabId, + targetId, + targetTabIds + ) + const { next: nextRetryHistory, removed: removedRetryHistory } = omitRemovedSshTargetRecovery( + state.directSshPaneRetryHistoryByTabId, + targetId, + targetTabIds + ) const nextDeferredTargets = state.deferredSshReconnectTargets.filter((id) => id !== targetId) const nextTransientClearedConnections = { @@ -210,7 +245,10 @@ export function buildRemovedSshTargetCleanupPatch( removedCredentialRequest || removedDeferredTarget || removedDeferredSession || - removedPendingReconnect + removedPendingReconnect || + removedPaneRetries || + removedLiveBindings || + removedRetryHistory if (!changed) { return null } @@ -237,6 +275,9 @@ export function buildRemovedSshTargetCleanupPatch( ...(removedCredentialRequest ? { sshCredentialQueue: nextCredentialQueue } : {}), ...(removedDeferredTarget ? { deferredSshReconnectTargets: nextDeferredTargets } : {}), ...(removedDeferredSession ? { deferredSshSessionIdsByTabId: nextDeferredSessions } : {}), - ...(removedPendingReconnect ? { pendingReconnectPtyIdByTabId: nextPendingReconnect } : {}) + ...(removedPendingReconnect ? { pendingReconnectPtyIdByTabId: nextPendingReconnect } : {}), + ...(removedPaneRetries ? { directSshPaneRetryByTabId: nextPaneRetries } : {}), + ...(removedLiveBindings ? { directSshLivePtyBindingByTabId: nextLiveBindings } : {}), + ...(removedRetryHistory ? { directSshPaneRetryHistoryByTabId: nextRetryHistory } : {}) } } diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index f12f12bca82..00ef5532140 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { createTestStore, makeTab, makeWorktree, TEST_REPO } from './store-test-helpers' describe('createSshSlice', () => { @@ -12,6 +13,16 @@ describe('createSshSlice', () => { const removedPtyId = toAppSshPtyId(targetId, 'pty-live') const staleLastKnownPtyId = toAppSshPtyId(targetId, 'pty-last-known') const otherPtyId = toAppSshPtyId(otherTargetId, 'pty-other') + const removedAuthority = { + targetId, + providerEpoch: 'removed-epoch' as SshProviderEpoch, + connectionGeneration: 1 + } as const + const otherAuthority = { + targetId: otherTargetId, + providerEpoch: 'other-epoch' as SshProviderEpoch, + connectionGeneration: 2 + } as const store.setState({ repos: [ @@ -114,7 +125,44 @@ describe('createSshSlice', () => { 'tab-ssh': toAppSshPtyId(targetId, 'pty-1'), 'tab-stale-encoded': toAppSshPtyId(targetId, 'pty-9'), 'tab-other': toAppSshPtyId(otherTargetId, 'pty-2') - } + }, + directSshPaneRetryByTabId: { + 'tab-ssh': { + attemptId: 'removed-attempt', + authority: removedAuthority, + tabGeneration: 1, + startedAt: 10 + }, + 'tab-stale-retry': { + attemptId: 'stale-removed-attempt', + authority: removedAuthority, + tabGeneration: 2, + startedAt: 11 + }, + 'tab-other': { + attemptId: 'other-attempt', + authority: otherAuthority, + tabGeneration: 3, + startedAt: 12 + } + } as never, + directSshLivePtyBindingByTabId: { + 'tab-ssh': { + authority: removedAuthority, + tabGeneration: 1, + ptyId: removedPtyId + }, + 'tab-other': { + authority: otherAuthority, + tabGeneration: 3, + ptyId: otherPtyId + } + } as never, + directSshPaneRetryHistoryByTabId: { + 'tab-ssh': { authority: removedAuthority, attemptedAt: [10] }, + 'tab-stale-retry': { authority: removedAuthority, attemptedAt: [11] }, + 'tab-other': { authority: otherAuthority, attemptedAt: [12] } + } as never }) store.getState().clearRemovedSshTargetState(targetId) @@ -139,6 +187,12 @@ describe('createSshSlice', () => { expect(state.pendingReconnectPtyIdByTabId).toEqual({ 'tab-other': toAppSshPtyId(otherTargetId, 'pty-2') }) + expect(Object.keys(state.directSshPaneRetryByTabId)).toEqual(['tab-other']) + expect(Object.keys(state.directSshLivePtyBindingByTabId)).toEqual(['tab-other']) + expect(Object.keys(state.directSshPaneRetryHistoryByTabId)).toEqual(['tab-other']) + expect(state.directSshPaneRetryByTabId['tab-other']?.authority.targetId).toBe(otherTargetId) + expect(state.directSshLivePtyBindingByTabId['tab-other']?.ptyId).toBe(otherPtyId) + expect(state.directSshPaneRetryHistoryByTabId['tab-other']?.attemptedAt).toEqual([12]) expect(state.tabsByWorktree[worktreeId][0]).toMatchObject({ id: 'tab-ssh', ptyId: null }) expect('pendingActivationSpawn' in state.tabsByWorktree[worktreeId][0]).toBe(false) expect(state.ptyIdsByTabId['tab-ssh']).toEqual([]) @@ -163,6 +217,107 @@ describe('createSshSlice', () => { }) }) + it("preserves another SSH target's terminal ledgers when repo ids collide", () => { + const store = createTestStore() + const removedTargetId = 'target-a' + const survivingTargetId = 'target-b' + const removedWorktreeId = 'shared::/srv/a/work' + const survivingWorktreeId = 'shared::/srv/b/work' + const removedPtyId = toAppSshPtyId(removedTargetId, 'pty-a') + const survivingPtyId = toAppSshPtyId(survivingTargetId, 'pty-b') + const removedAuthority = { + targetId: removedTargetId, + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const survivingAuthority = { + targetId: survivingTargetId, + providerEpoch: 'epoch-b' as SshProviderEpoch, + connectionGeneration: 2 + } + const survivingAttempt = { + attemptId: 'attempt-b', + authority: survivingAuthority, + tabGeneration: 4, + startedAt: 20 + } + const survivingBinding = { + authority: survivingAuthority, + tabGeneration: 4, + ptyId: survivingPtyId + } + const survivingHistory = { authority: survivingAuthority, attemptedAt: [20] } + + store.setState({ + repos: [ + { + ...TEST_REPO, + id: 'shared', + connectionId: removedTargetId, + executionHostId: 'ssh:target-a' + }, + { + ...TEST_REPO, + id: 'shared', + connectionId: survivingTargetId, + executionHostId: 'ssh:target-b' + } + ], + worktreesByRepo: { + shared: [ + makeWorktree({ + id: removedWorktreeId, + repoId: 'shared', + hostId: 'ssh:target-a' + }), + makeWorktree({ + id: survivingWorktreeId, + repoId: 'shared', + hostId: 'ssh:target-b' + }) + ] + }, + tabsByWorktree: { + [removedWorktreeId]: [ + makeTab({ id: 'tab-a', worktreeId: removedWorktreeId, ptyId: removedPtyId }) + ], + [survivingWorktreeId]: [ + makeTab({ id: 'tab-b', worktreeId: survivingWorktreeId, ptyId: survivingPtyId }) + ] + }, + ptyIdsByTabId: { 'tab-a': [removedPtyId], 'tab-b': [survivingPtyId] }, + lastKnownRelayPtyIdByTabId: { 'tab-a': removedPtyId, 'tab-b': survivingPtyId }, + directSshPaneRetryByTabId: { + 'tab-a': { + attemptId: 'attempt-a', + authority: removedAuthority, + tabGeneration: 3, + startedAt: 10 + }, + 'tab-b': survivingAttempt + } as never, + directSshLivePtyBindingByTabId: { + 'tab-a': { authority: removedAuthority, tabGeneration: 3, ptyId: removedPtyId }, + 'tab-b': survivingBinding + } as never, + directSshPaneRetryHistoryByTabId: { + 'tab-a': { authority: removedAuthority, attemptedAt: [10] }, + 'tab-b': survivingHistory + } as never + }) + + store.getState().clearRemovedSshTargetState(removedTargetId) + + const state = store.getState() + expect(state.tabsByWorktree[removedWorktreeId][0]?.ptyId).toBeNull() + expect(state.tabsByWorktree[survivingWorktreeId][0]?.ptyId).toBe(survivingPtyId) + expect(state.ptyIdsByTabId['tab-b']).toEqual([survivingPtyId]) + expect(state.lastKnownRelayPtyIdByTabId['tab-b']).toBe(survivingPtyId) + expect(state.directSshPaneRetryByTabId['tab-b']).toBe(survivingAttempt) + expect(state.directSshLivePtyBindingByTabId['tab-b']).toBe(survivingBinding) + expect(state.directSshPaneRetryHistoryByTabId['tab-b']).toBe(survivingHistory) + }) + it('keeps SSH target label references stable when refreshed metadata is unchanged', () => { const store = createTestStore() const labels = new Map([['ssh-1', 'Remote']]) @@ -233,6 +388,31 @@ describe('createSshSlice', () => { expect(store.getState().sshConnectionStates.get('ssh-1')?.connectionGeneration).toBe(2) }) + it('publishes an authoritative SSH provider epoch change', () => { + const store = createTestStore() + store.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'provider-a' as never, + connectionGeneration: 1 + }) + const previousState = store.getState() + + store.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'provider-b' as never, + connectionGeneration: 1 + }) + + expect(store.getState()).not.toBe(previousState) + expect(store.getState().sshConnectionStates.get('ssh-1')?.providerEpoch).toBe('provider-b') + }) + it('publishes a connected-state folder capability change', () => { const store = createTestStore() store.getState().setSshConnectionState('ssh-1', { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 0bcedd99a8a..57bf015adec 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { buildWorktreeComparator } from '@/components/sidebar/smart-sort' import type * as AgentStatusModule from '@/lib/agent-status' import { getDefaultSettings } from '../../../../shared/constants' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery' import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests, @@ -1302,6 +1304,140 @@ describe('setActiveWorktree', () => { ) }) + it('moves current direct SSH binding evidence when detaching a live pane', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const detachedPtyId = 'ssh:target-a@@pty-detached' + seedStore(store, { + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: detachedPtyId }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { [sourceTabId]: [detachedPtyId], [targetTabId]: [] }, + directSshLivePtyBindingByTabId: { + [sourceTabId]: { + attemptId: 'live-detach' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + ptyId: detachedPtyId + } + }, + directSshPaneRetryHistoryByTabId: { + [sourceTabId]: { authority, attemptedAt: [1] } + }, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: '11111111-1111-4111-8111-111111111111', + detachedPtyId, + sourceLayout: makeLayout(), + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.directSshPaneRetryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshPaneRetryHistoryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[targetTabId]).toEqual({ + attemptId: 'live-detach', + authority, + tabGeneration: 0, + ptyId: detachedPtyId + }) + expect(state.directSshPaneRetryHistoryByTabId[targetTabId]).toEqual({ + authority, + attemptedAt: [1] + }) + }) + + it('rearms a current pending SSH detach as live destination evidence', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const detachedPtyId = 'ssh:target-a@@pty-detached' + seedStore(store, { + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: detachedPtyId, generation: 2 }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { [sourceTabId]: [detachedPtyId], [targetTabId]: [] }, + directSshPaneRetryByTabId: { + [sourceTabId]: { + attemptId: 'pending-detach' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 2, + startedAt: 1 + } + }, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: '11111111-1111-4111-8111-111111111111', + detachedPtyId, + sourceLayout: makeLayout(), + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.directSshPaneRetryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshPaneRetryHistoryByTabId[sourceTabId]).toBeUndefined() + expect(state.directSshLivePtyBindingByTabId[targetTabId]).toEqual({ + attemptId: 'pending-detach', + authority, + tabGeneration: 0, + ptyId: detachedPtyId + }) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + detachedPtyId + ) + }) + // Regression for #9911: a split SSH tab's single relay slot points at the // last-bound pane; when it exits, clearTabPtyId must promote a surviving pane // instead of clearing, or a later relay-drop bulk-clear leaves the survivor diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 2d521a43b1e..30bcdb24925 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -104,7 +104,13 @@ const mockApi = { // @ts-expect-error -- mock globalThis.window = { api: mockApi } -import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers' +import { + createTestStore, + makeOpenFile, + makeTabGroup, + makeUnifiedTab, + makeWorktree +} from './store-test-helpers' const WT = 'repo1::/tmp/feature' @@ -1783,6 +1789,158 @@ describe('TabsSlice', () => { expect(store.getState().unifiedTabsByWorktree).toEqual({}) }) + + it('replaces only explicitly scoped worktree tab chrome', () => { + const siblingWorktreeId = 'repo2::/tmp/sibling' + const targetGroup = makeTabGroup({ + id: 'group-target', + worktreeId: WT, + activeTabId: 'target-old', + tabOrder: ['target-old'] + }) + const siblingGroup = makeTabGroup({ + id: 'group-sibling', + worktreeId: siblingWorktreeId, + activeTabId: 'sibling-tab', + tabOrder: ['sibling-tab'] + }) + const siblingTabs = [ + makeUnifiedTab({ + id: 'sibling-tab', + worktreeId: siblingWorktreeId, + groupId: siblingGroup.id + }) + ] + const siblingGroups = [siblingGroup] + store.setState({ + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1' })], + repo2: [makeWorktree({ id: siblingWorktreeId, repoId: 'repo2' })] + }, + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'target-old', worktreeId: WT, groupId: targetGroup.id })], + [siblingWorktreeId]: siblingTabs + }, + groupsByWorktree: { + [WT]: [targetGroup], + [siblingWorktreeId]: siblingGroups + }, + activeGroupIdByWorktree: { + [WT]: targetGroup.id, + [siblingWorktreeId]: siblingGroup.id + }, + layoutByWorktree: { + [WT]: { type: 'leaf', groupId: targetGroup.id }, + [siblingWorktreeId]: { type: 'leaf', groupId: siblingGroup.id } + } + }) + const targetNew = makeUnifiedTab({ + id: 'target-new', + worktreeId: WT, + groupId: targetGroup.id, + label: 'Remote target' + }) + + store.getState().hydrateTabsSession( + { + activeRepoId: 'repo1', + activeWorktreeId: WT, + activeTabId: targetNew.id, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + [WT]: [targetNew], + [siblingWorktreeId]: [ + makeUnifiedTab({ + id: 'sibling-replaced', + worktreeId: siblingWorktreeId, + groupId: siblingGroup.id + }) + ] + }, + tabGroups: { + [WT]: [{ ...targetGroup, activeTabId: targetNew.id, tabOrder: [targetNew.id] }], + [siblingWorktreeId]: [ + { ...siblingGroup, activeTabId: 'sibling-replaced', tabOrder: ['sibling-replaced'] } + ] + } + }, + { replaceWorkspaceKeys: [WT] } + ) + + expect(store.getState().unifiedTabsByWorktree[WT]).toEqual([targetNew]) + expect(store.getState().unifiedTabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(store.getState().groupsByWorktree[siblingWorktreeId]).toBe(siblingGroups) + }) + + it('deletes omitted target chrome while preserving sibling references', () => { + const siblingWorktreeId = 'repo2::/tmp/sibling' + const targetGroup = makeTabGroup({ + id: 'group-target', + worktreeId: WT, + activeTabId: 'target-tab', + tabOrder: ['target-tab'] + }) + const siblingGroup = makeTabGroup({ + id: 'group-sibling', + worktreeId: siblingWorktreeId, + activeTabId: 'sibling-tab', + tabOrder: ['sibling-tab'] + }) + const siblingTabs = [ + makeUnifiedTab({ + id: 'sibling-tab', + worktreeId: siblingWorktreeId, + groupId: siblingGroup.id + }) + ] + const siblingGroups = [siblingGroup] + const siblingLayout = { type: 'leaf' as const, groupId: siblingGroup.id } + store.setState({ + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1' })], + repo2: [makeWorktree({ id: siblingWorktreeId, repoId: 'repo2' })] + }, + unifiedTabsByWorktree: { + [WT]: [makeUnifiedTab({ id: 'target-tab', worktreeId: WT, groupId: targetGroup.id })], + [siblingWorktreeId]: siblingTabs + }, + groupsByWorktree: { + [WT]: [targetGroup], + [siblingWorktreeId]: siblingGroups + }, + activeGroupIdByWorktree: { + [WT]: targetGroup.id, + [siblingWorktreeId]: siblingGroup.id + }, + layoutByWorktree: { + [WT]: { type: 'leaf', groupId: targetGroup.id }, + [siblingWorktreeId]: siblingLayout + } + }) + + store.getState().hydrateTabsSession( + { + activeRepoId: 'repo1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: {}, + tabGroups: {} + }, + { replaceWorkspaceKeys: [WT] } + ) + + const state = store.getState() + expect(state.unifiedTabsByWorktree).not.toHaveProperty(WT) + expect(state.groupsByWorktree).not.toHaveProperty(WT) + expect(state.activeGroupIdByWorktree).not.toHaveProperty(WT) + expect(state.layoutByWorktree).not.toHaveProperty(WT) + expect(state.unifiedTabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(state.groupsByWorktree[siblingWorktreeId]).toBe(siblingGroups) + expect(state.layoutByWorktree[siblingWorktreeId]).toBe(siblingLayout) + }) }) // ─── Cross-content-type neighbor selection ──────────────────────── diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 3c070f10711..b38aa980017 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -47,6 +47,17 @@ import { export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' +function replaceWorkspaceRecordKeys( + current: Record, + hydrated: Record, + workspaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !workspaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => workspaceKeys.has(key))) + } +} + export type TabsSlice = { unifiedTabsByWorktree: Record // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. @@ -2011,6 +2022,33 @@ export const createTabsSlice: StateCreator = (set, validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - set(buildHydratedTabState(session, validWorktreeIds)) + const hydrated = buildHydratedTabState(session, validWorktreeIds) + if (!options?.replaceWorkspaceKeys) { + set(hydrated) + return + } + const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) + set((current) => ({ + unifiedTabsByWorktree: replaceWorkspaceRecordKeys( + current.unifiedTabsByWorktree, + hydrated.unifiedTabsByWorktree, + replaceWorkspaceKeys + ), + groupsByWorktree: replaceWorkspaceRecordKeys( + current.groupsByWorktree, + hydrated.groupsByWorktree, + replaceWorkspaceKeys + ), + activeGroupIdByWorktree: replaceWorkspaceRecordKeys( + current.activeGroupIdByWorktree, + hydrated.activeGroupIdByWorktree, + replaceWorkspaceKeys + ), + layoutByWorktree: replaceWorkspaceRecordKeys( + current.layoutByWorktree, + hydrated.layoutByWorktree, + replaceWorkspaceKeys + ) + })) } }) diff --git a/src/renderer/src/store/slices/terminal-activation-spawn-suppression.ts b/src/renderer/src/store/slices/terminal-activation-spawn-suppression.ts new file mode 100644 index 00000000000..203736af29d --- /dev/null +++ b/src/renderer/src/store/slices/terminal-activation-spawn-suppression.ts @@ -0,0 +1,22 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' + +function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { + if (!node) { + return 0 + } + if (node.type === 'leaf') { + return 1 + } + return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second) +} + +export function getTerminalActivationSpawnSuppression( + layout: TerminalLayoutSnapshot | undefined +): true | number { + const paneCount = Math.max( + 1, + countTerminalLayoutLeaves(layout?.root), + Object.keys(layout?.ptyIdsByLeafId ?? {}).length + ) + return paneCount === 1 ? true : paneCount +} diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index 0768d70bc6d..45445f530bd 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -48,6 +48,7 @@ type OrphanTerminalCleanupState = Pick< | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' | 'nativeChatLaunchPromptByTabId' + | 'nativeChatLaunchDraftByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -99,6 +100,7 @@ export function buildOrphanTerminalCleanupPatch( | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' | 'nativeChatLaunchPromptByTabId' + | 'nativeChatLaunchDraftByTabId' | 'tabBarOrderByWorktree' | 'cacheTimerByKey' | 'activeTabIdByWorktree' @@ -118,6 +120,7 @@ export function buildOrphanTerminalCleanupPatch( pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: state.automaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: state.nativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, tabBarOrderByWorktree: state.tabBarOrderByWorktree, cacheTimerByKey: state.cacheTimerByKey, activeTabIdByWorktree: state.activeTabIdByWorktree, @@ -141,6 +144,7 @@ export function buildOrphanTerminalCleanupPatch( ...state.automaticAgentResumeClaimsByTabId } const nextNativeChatLaunchPromptByTabId = { ...state.nativeChatLaunchPromptByTabId } + const nextNativeChatLaunchDraftByTabId = { ...state.nativeChatLaunchDraftByTabId } const nextTabBarOrderByWorktree = { ...state.tabBarOrderByWorktree, [worktreeId]: (state.tabBarOrderByWorktree[worktreeId] ?? []).filter( @@ -165,6 +169,7 @@ export function buildOrphanTerminalCleanupPatch( delete nextPendingIssueCommandSplitByTabId[orphanTabId] delete nextAutomaticAgentResumeClaimsByTabId[orphanTabId] delete nextNativeChatLaunchPromptByTabId[orphanTabId] + delete nextNativeChatLaunchDraftByTabId[orphanTabId] for (const key of Object.keys(nextCacheTimerByKey)) { if (key.startsWith(`${orphanTabId}:`)) { delete nextCacheTimerByKey[key] @@ -195,6 +200,7 @@ export function buildOrphanTerminalCleanupPatch( pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, tabBarOrderByWorktree: nextTabBarOrderByWorktree, cacheTimerByKey: nextCacheTimerByKey, activeTabIdByWorktree: nextActiveTabIdByWorktree, diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index d3865eecebf..837fde338fd 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -92,6 +92,8 @@ const mockApi = { globalThis.window = { api: mockApi } import type { WorkspaceSessionState } from '../../../../shared/types' +import type { SshProviderEpoch } from '../../../../shared/ssh-types' +import type { DirectSshPaneRetryAttemptId } from './direct-ssh-terminal-recovery-types' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession @@ -710,6 +712,156 @@ describe('hydrateWorkspaceSession', () => { expect(store.getState().worktreeNavHistoryIndex).toBe(1) expect(canGoBackWorktreeHistory(store.getState())).toBe(true) }) + + it('hydrates and reconnects one SSH target without mutating unrelated state', async () => { + const store = createTestStore() + const targetWorktreeId = 'repo-a::/target' + const siblingWorktreeId = 'repo-b::/sibling' + const localWorktreeId = 'repo-local::/local' + const runtimeWorktreeId = 'repo-runtime::/runtime' + const folderWorktreeId = folderWorkspaceKey('folder-1') + const targetTab = makeTab({ id: 'tab-target', worktreeId: targetWorktreeId, ptyId: null }) + const deletedTargetTab = makeTab({ id: 'tab-target-deleted', worktreeId: targetWorktreeId }) + const siblingTab = makeTab({ + id: 'tab-sibling', + worktreeId: siblingWorktreeId, + ptyId: 'ssh:target-b@@pty-b' + }) + const runtimeTab = makeTab({ + id: 'tab-runtime', + worktreeId: runtimeWorktreeId, + ptyId: 'runtime:env@@pty-runtime' + }) + const localTab = makeTab({ id: 'tab-local', worktreeId: localWorktreeId, ptyId: null }) + const folderTab = makeTab({ id: 'tab-folder', worktreeId: folderWorktreeId, ptyId: null }) + const siblingTabs = [siblingTab] + const runtimeTabs = [runtimeTab] + const [localTabs, folderTabs] = [[localTab], [folderTab]] + const runtimeOwners = { [runtimeWorktreeId]: 'runtime:env' as const } + const authority = { + targetId: 'target-a', + providerEpoch: 'epoch-a' as SshProviderEpoch, + connectionGeneration: 7 + } + const ledgerTabs = [targetTab, deletedTargetTab, siblingTab] + const retry = { + attemptId: 'attempt' as DirectSshPaneRetryAttemptId, + authority, + tabGeneration: 0, + startedAt: 0 + } + const retryByTabId = Object.fromEntries(ledgerTabs.map((tab) => [tab.id, retry])) + const liveBinding = { ...retry, ptyId: 'ssh:target-a@@pty-ledger' } + const liveByTabId = Object.fromEntries(ledgerTabs.map((tab) => [tab.id, liveBinding])) + const historyByTabId = Object.fromEntries( + ledgerTabs.map((tab) => [tab.id, { authority, attemptedAt: [0] }]) + ) + seedStore(store, { + workspaceSessionReady: true, + repos: [ + { ...TEST_REPO, id: 'repo-a', connectionId: 'target-a' }, + { ...TEST_REPO, id: 'repo-b', connectionId: 'target-b' } + ], + worktreesByRepo: { + 'repo-a': [makeWorktree({ id: targetWorktreeId, repoId: 'repo-a', path: '/target' })], + 'repo-b': [makeWorktree({ id: siblingWorktreeId, repoId: 'repo-b', path: '/sibling' })] + }, + tabsByWorktree: { + [targetWorktreeId]: [targetTab, deletedTargetTab], + [siblingWorktreeId]: siblingTabs, + [localWorktreeId]: localTabs, + [runtimeWorktreeId]: runtimeTabs, + [folderWorktreeId]: folderTabs + }, + ptyIdsByTabId: { + [targetTab.id]: [], + [siblingTab.id]: [siblingTab.ptyId!], + [runtimeTab.id]: [runtimeTab.ptyId!] + }, + activeRepoId: 'repo-b', + activeWorktreeId: siblingWorktreeId, + activeTabId: siblingTab.id, + restoredRuntimeHostIdByWorkspaceSessionKey: runtimeOwners, + directSshPaneRetryByTabId: retryByTabId, + directSshLivePtyBindingByTabId: liveByTabId, + directSshPaneRetryHistoryByTabId: historyByTabId, + sshConnectionStates: new Map([ + [ + authority.targetId, + { + targetId: authority.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + ] + ]) + }) + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-a', + activeWorktreeId: targetWorktreeId, + activeTabId: targetTab.id, + tabsByWorktree: { + [targetWorktreeId]: [ + { ...targetTab, ptyId: 'ssh:target-a@@pty-a' }, + makeTab({ + id: 'tab-wrong-host', + worktreeId: targetWorktreeId, + ptyId: 'ssh:target-b@@pty-wrong' + }) + ], + [siblingWorktreeId]: siblingTabs, + [runtimeWorktreeId]: runtimeTabs + }, + activeWorktreeIdsOnShutdown: [targetWorktreeId], + terminalLayoutsByTabId: {} + } + + store.getState().hydrateWorkspaceSession(session, { + directSshAuthority: authority, + replaceWorkspaceKeys: [targetWorktreeId] + }) + + expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(store.getState().tabsByWorktree[localWorktreeId]).toBe(localTabs) + expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) + expect(store.getState().tabsByWorktree[folderWorktreeId]).toBe(folderTabs) + expect(store.getState().ptyIdsByTabId[siblingTab.id]).toEqual([siblingTab.ptyId]) + expect(store.getState().ptyIdsByTabId[runtimeTab.id]).toEqual([runtimeTab.ptyId]) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toBe(runtimeOwners) + expect(store.getState().activeWorktreeId).toBe(siblingWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + [targetTab.id]: 'ssh:target-a@@pty-a' + }) + const retainedLedgerTabIds = ledgerTabs + .filter((tab) => tab.id !== deletedTargetTab.id) + .map((tab) => tab.id) + .sort() + expect(Object.keys(store.getState().directSshPaneRetryByTabId).sort()).toEqual( + retainedLedgerTabIds + ) + expect(Object.keys(store.getState().directSshLivePtyBindingByTabId).sort()).toEqual( + retainedLedgerTabIds + ) + expect(Object.keys(store.getState().directSshPaneRetryHistoryByTabId).sort()).toEqual( + retainedLedgerTabIds + ) + + await store.getState().reconnectPersistedTerminals(undefined, { + directSshAuthority: authority, + workspaceKeys: [targetWorktreeId] + }) + + const targetTabs = store.getState().tabsByWorktree[targetWorktreeId] + expect(targetTabs.find((tab) => tab.id === targetTab.id)?.ptyId).toBe('ssh:target-a@@pty-a') + expect(targetTabs.find((tab) => tab.id === 'tab-wrong-host')?.ptyId).toBeNull() + expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) + expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) + expect(store.getState().workspaceSessionReady).toBe(true) + }) }) describe('hydrationSucceeded flag (issue #1158)', () => { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 2720ce9a520..09911ad8ba9 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -16,6 +16,8 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { DEFAULT_REPO_BADGE_COLOR, FLOATING_TERMINAL_WORKTREE_ID @@ -35,6 +37,7 @@ import { } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' +import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem @@ -91,6 +94,25 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep' import { createBrowserUuid } from '@/lib/browser-uuid' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { + clearDirectSshTerminalBindings, + invalidateStaleDirectSshTerminalBindings, + type DirectSshLivePtyBinding, + type DirectSshPaneRetryAttempt, + type DirectSshPaneRetryAttemptId, + type DirectSshPaneRetryHistory, + type DirectSshPaneRetryResult +} from './direct-ssh-terminal-recovery' +import { + retryDirectSshTerminalPanes, + retrySettledDirectSshTerminalPane +} from './direct-ssh-pane-retry-ledger' +import { + directSshAuthoritiesEqual, + settleDirectSshPaneRetryState, + transferDirectSshPaneDetachLedger +} from './direct-ssh-terminal-authority-ledger' +import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -98,7 +120,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -143,6 +165,32 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null } +function isCurrentDirectSshAuthority(state: AppState, authority: DirectSshAuthority): boolean { + const current = state.sshConnectionStates.get(authority.targetId) + return Boolean( + current?.status === 'connected' && + current.providerEpoch === authority.providerEpoch && + current.connectionGeneration === authority.connectionGeneration + ) +} + +function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { + return resolveDirectSshTerminalWorkspaceKeys( + { + targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }, + state.tabsByWorktree, + state.lastKnownRelayPtyIdByTabId + ) +} + function getPendingActivationSpawnCount(value: boolean | number | undefined): number { if (value === true) { return 1 @@ -465,9 +513,14 @@ export type AutomaticAgentResumeClaim = { export type CodexRestartNotice = { previousAccountLabel: string nextAccountLabel: string + /** Ids behind the two labels, when the caller knows them (`null` is the system + * default). Two accounts can share a label, so only these can decide whether + * a re-mark actually points back at the account the pane launched under. */ + previousAccountId?: string | null + nextAccountId?: string | null /** Set once the user asks for the restart. The record outlives the prompt - * because `previousAccountLabel` is the only memory of the account this pane - * actually launched under, which drives the A -> B -> A collapse. */ + * because the previous-account fields are the only memory of the account this + * pane actually launched under, which drives the A -> B -> A collapse. */ restartRequested?: true /** Set when the user answers "Keep old account". Same reason the record has to * survive: deleting it erased the launch account, so re-selecting it looked @@ -495,6 +548,9 @@ export type TerminalSlice = { pendingPtyShutdownIds: Record pendingCodexPaneRestartIds: Record codexRestartNoticeByPtyId: Record + directSshPaneRetryByTabId: Record + directSshLivePtyBindingByTabId: Record + directSshPaneRetryHistoryByTabId: Record expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record @@ -509,6 +565,11 @@ export type TerminalSlice = { seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void markNativeChatLaunchPromptFailed: (tabId: string) => void clearNativeChatLaunchPrompt: (tabId: string) => void + /** Launch context prefilled into the TUI input as an unsent draft; the chat composer adopts it. In-memory only. */ + nativeChatLaunchDraftByTabId: Record + seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void + markNativeChatLaunchDraftAdopted: (tabId: string) => void + clearNativeChatLaunchDraft: (tabId: string) => void pendingStartupByTabId: Record< string, { @@ -624,8 +685,17 @@ export type TerminalSlice = { opts?: { recordInteraction?: boolean } ) => void setTabColor: (tabId: string, color: string | null) => void - updateTabPtyId: (tabId: string, ptyId: string, replacedPtyId?: string) => void + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void clearTabPtyId: (tabId: string, ptyId?: string) => void + clearDirectSshTargetPtyBindings: (targetId: string) => number + invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number + retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number + settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void shutdownWorktreeTerminals: ( worktreeId: string, opts?: { @@ -650,9 +720,14 @@ export type TerminalSlice = { isPtyShutdownPending: (ptyId: string) => boolean queueCodexPaneRestarts: (ptyIds: string[]) => void consumePendingCodexPaneRestart: (ptyId: string) => boolean + /** Returns the ptyIds left holding a notice, so callers can tell a raised + * prompt from one the A -> B -> A collapse dropped. */ markCodexRestartNotices: ( - notices: { ptyId: string; previousAccountLabel: string; nextAccountLabel: string }[] - ) => void + notices: (Pick & + Partial> & { + ptyId: string + })[] + ) => string[] clearCodexRestartNotice: (ptyId: string) => void dismissCodexRestartNotices: (ptyIds: string[]) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void @@ -737,13 +812,186 @@ export type TerminalSlice = { session: WorkspaceSessionState, options?: HydrateWorkspaceSessionOptions ) => void - reconnectPersistedTerminals: (signal?: AbortSignal) => Promise + reconnectPersistedTerminals: ( + signal?: AbortSignal, + options?: ReconnectPersistedTerminalsOptions + ) => Promise } export type HydrateWorkspaceSessionOptions = { + directSshAuthority?: DirectSshAuthority runtimeHostIdByWorkspaceSessionKey?: Record } & WorkspaceSessionHydrationOptions +export type ReconnectPersistedTerminalsOptions = { + directSshAuthority: DirectSshAuthority + workspaceKeys: readonly string[] +} + +type WorkspaceHydrationPatch = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'activeWorkspaceKey' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'repos' + | 'tabsByWorktree' + | 'worktreesByRepo' + | 'lastVisitedAtByWorktreeId' + | 'defaultTerminalTabsAppliedByWorktreeId' + | 'automaticAgentResumeClaimsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'pendingReconnectWorktreeIds' + | 'pendingReconnectTabByWorktree' + | 'pendingReconnectPtyIdByTabId' + | 'everActivatedWorktreeIds' + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' +> + +function replaceHydratedRecordKeys( + current: Record, + hydrated: Record, + replaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key))) + } +} + +function targetScopedWorkspaceHydrationPatch( + state: AppState, + hydrated: WorkspaceHydrationPatch, + session: WorkspaceSessionState, + options: HydrateWorkspaceSessionOptions +): Partial { + const workspaceKeys = new Set(options.replaceWorkspaceKeys) + const targetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => [ + ...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id), + ...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ]) + ) + const retainedTargetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => + (hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deletedTargetTabIds = new Set( + [...workspaceKeys] + .flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)) + .filter((tabId) => !retainedTargetTabIds.has(tabId)) + ) + const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys( + state.pendingReconnectPtyIdByTabId, + {}, + targetTabIds + ) + const authority = options.directSshAuthority + if (authority) { + for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { + if (!workspaceKeys.has(workspaceKey)) { + continue + } + for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { + const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId + if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { + pendingReconnectPtyIdByTabId[tab.id] = ptyId + } + } + } + } + const activeOutsideScope = + state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) + const sleepingAgentSessionsByPaneKey = Object.fromEntries([ + ...Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([, record]) => !workspaceKeys.has(record.worktreeId) + ), + ...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) => + workspaceKeys.has(record.worktreeId) + ) + ]) + const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds) + for (const workspaceKey of hydrated.everActivatedWorktreeIds) { + if (workspaceKeys.has(workspaceKey)) { + everActivatedWorktreeIds.add(workspaceKey) + } + } + return { + activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId, + activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId, + activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey, + activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId, + activeTabIdByWorktree: replaceHydratedRecordKeys( + state.activeTabIdByWorktree, + hydrated.activeTabIdByWorktree, + workspaceKeys + ), + tabsByWorktree: replaceHydratedRecordKeys( + state.tabsByWorktree, + hydrated.tabsByWorktree, + workspaceKeys + ), + lastVisitedAtByWorktreeId: replaceHydratedRecordKeys( + state.lastVisitedAtByWorktreeId, + hydrated.lastVisitedAtByWorktreeId, + workspaceKeys + ), + defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys( + state.defaultTerminalTabsAppliedByWorktreeId, + hydrated.defaultTerminalTabsAppliedByWorktreeId, + workspaceKeys + ), + automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( + state.automaticAgentResumeClaimsByTabId, + hydrated.automaticAgentResumeClaimsByTabId, + targetTabIds + ), + sleepingAgentSessionsByPaneKey, + pendingReconnectWorktreeIds: [ + ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), + ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) + ], + pendingReconnectTabByWorktree: replaceHydratedRecordKeys( + state.pendingReconnectTabByWorktree, + hydrated.pendingReconnectTabByWorktree, + workspaceKeys + ), + pendingReconnectPtyIdByTabId, + everActivatedWorktreeIds, + directSshPaneRetryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryByTabId, + {}, + deletedTargetTabIds + ), + directSshLivePtyBindingByTabId: replaceHydratedRecordKeys( + state.directSshLivePtyBindingByTabId, + {}, + deletedTargetTabIds + ), + directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryHistoryByTabId, + {}, + deletedTargetTabIds + ), + ptyIdsByTabId: replaceHydratedRecordKeys( + state.ptyIdsByTabId, + hydrated.ptyIdsByTabId, + targetTabIds + ), + terminalLayoutsByTabId: replaceHydratedRecordKeys( + state.terminalLayoutsByTabId, + hydrated.terminalLayoutsByTabId, + targetTabIds + ) + } +} + export const createTerminalSlice: StateCreator = (set, get) => ({ tabsByWorktree: {}, activeTabId: null, @@ -757,6 +1005,9 @@ export const createTerminalSlice: StateCreator pendingPtyShutdownIds: {}, pendingCodexPaneRestartIds: {}, codexRestartNoticeByPtyId: {}, + directSshPaneRetryByTabId: {}, + directSshLivePtyBindingByTabId: {}, + directSshPaneRetryHistoryByTabId: {}, expandedPaneByTabId: {}, canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, @@ -766,6 +1017,7 @@ export const createTerminalSlice: StateCreator pendingIssueCommandSplitByTabId: {}, automaticAgentResumeClaimsByTabId: {}, nativeChatLaunchPromptByTabId: {}, + nativeChatLaunchDraftByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, restoredRuntimeHostIdByWorkspaceSessionKey: {}, @@ -851,6 +1103,41 @@ export const createTerminalSlice: StateCreator }) }, + seedNativeChatLaunchDraft: (draft) => { + set((s) => ({ + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [draft.tabId]: draft + } + })) + }, + + markNativeChatLaunchDraftAdopted: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if (!current || current.adopted) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, adopted: true } + } + } + }) + }, + + clearNativeChatLaunchDraft: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchDraftByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchDraftByTabId } + delete next[tabId] + return { nativeChatLaunchDraftByTabId: next } + }) + }, + recordTerminalInput: (paneKey, timestamp = Date.now()) => { if (!paneKey || !Number.isFinite(timestamp)) { return @@ -1300,6 +1587,16 @@ export const createTerminalSlice: StateCreator delete nextPendingReconnectPtyIdByTabId[tabId] const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } delete nextRuntimePaneTitlesByTabId[tabId] + const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + const nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + delete nextDirectSshLivePtyBindingByTabId[tabId] + const nextDirectSshPaneRetryHistoryByTabId = { + ...s.directSshPaneRetryHistoryByTabId + } + delete nextDirectSshPaneRetryHistoryByTabId[tabId] // Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval. let nextUnreadTerminalTabs = s.unreadTerminalTabs if (s.unreadTerminalTabs[tabId]) { @@ -1339,6 +1636,8 @@ export const createTerminalSlice: StateCreator delete nextAutomaticAgentResumeClaimsByTabId[tabId] const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } delete nextNativeChatLaunchPromptByTabId[tabId] + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } + delete nextNativeChatLaunchDraftByTabId[tabId] const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } delete nextPendingInitialCwdByTabId[tabId] const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } @@ -1400,6 +1699,9 @@ export const createTerminalSlice: StateCreator deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, + directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } : {}), @@ -1420,6 +1722,7 @@ export const createTerminalSlice: StateCreator pendingStartupByTabId: nextPendingStartupByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, @@ -1906,7 +2209,7 @@ export const createTerminalSlice: StateCreator } }, - updateTabPtyId: (tabId, ptyId, replacedPtyId) => { + updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => { // Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown). if (!isTerminalTabPresent(get(), tabId)) { return @@ -1915,6 +2218,28 @@ export const createTerminalSlice: StateCreator let wasActivationSpawn = false const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) set((s) => { + if (directSshRetryAttemptId) { + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = Object.values(s.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if ( + !retryLease || + !boundTab || + parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId || + !isCurrentDirectSshAuthority(s, retryLease.authority) || + (boundTab.generation ?? 0) !== retryLease.tabGeneration + ) { + return s + } + } const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] const remote = parseRemoteRuntimePtyId(ptyId) const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null @@ -2017,6 +2342,67 @@ export const createTerminalSlice: StateCreator delete nextMigrationUnsupportedByPtyId[replacementPtyId] } } + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = worktreeId + ? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + : undefined + const parsedSshPty = parseAppSshPtyId(ptyId) + const acknowledgesDirectSshRetry = Boolean( + retryLease && + boundTab && + parsedSshPty?.connectionId === retryLease.authority.targetId && + isCurrentDirectSshAuthority(s, retryLease.authority) && + (boundTab.generation ?? 0) === retryLease.tabGeneration && + nextPtyIds.includes(ptyId) + ) + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) { + if (pendingRetry?.attemptId === directSshRetryAttemptId) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { + attemptId: retryLease.attemptId, + authority: retryLease.authority, + tabGeneration: retryLease.tabGeneration, + ptyId: boundTab.ptyId + } + } + } else if ( + (replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) || + !nextPtyIds.includes(liveRetry.ptyId) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveRetry, ptyId: boundTab.ptyId } + } + } + } else { + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] + if (liveBinding) { + if ( + replacementPtyId === liveBinding.ptyId && + boundTab?.ptyId === ptyId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveBinding, ptyId } + } + } + } + } return { ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ptyIdsByTabId: { @@ -2031,6 +2417,8 @@ export const createTerminalSlice: StateCreator pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) @@ -2048,10 +2436,12 @@ export const createTerminalSlice: StateCreator } let worktreeId: string | null = null let wasActivationSpawn = false + let preservesDirectSshContinuationGap = false let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) set((s) => { const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : [] + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] let nextTabsByWorktree = s.tabsByWorktree for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { const index = tabs.findIndex((t) => t.id === tabId) @@ -2071,8 +2461,16 @@ export const createTerminalSlice: StateCreator const { pendingActivationSpawn: _unused, ...rest } = tab void _unused const nextTabPtyId = remainingPtyIds.at(-1) ?? null + preservesDirectSshContinuationGap = Boolean( + ptyId && + remainingPtyIds.length === 0 && + liveBinding?.ptyId === ptyId && + getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) const shouldRetainActivationSpawn = - wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId) + preservesDirectSshContinuationGap || + (wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId)) const nextPendingActivationSpawn = shouldRetainActivationSpawn ? tab.pendingActivationSpawn : consumePendingActivationSpawn(tab.pendingActivationSpawn) @@ -2122,13 +2520,45 @@ export const createTerminalSlice: StateCreator delete nextLastKnownRelay[tabId] } } + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + if ( + pendingRetry && + (!ptyId || + (existingPtyIds.includes(ptyId) && + parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId)) + ) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined + if ( + promotedPtyId && + parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId[tabId] = { + ...liveBinding, + ptyId: promotedPtyId + } + } else if (!preservesDirectSshContinuationGap) { + delete nextDirectSshLivePtyBindingByTabId[tabId] + } + } return { ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ptyIdsByTabId: nextPtyIdsByTabId, lastKnownRelayPtyIdByTabId: nextLastKnownRelay, pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId } }) @@ -2376,6 +2806,84 @@ export const createTerminalSlice: StateCreator }) }, + clearDirectSshTargetPtyBindings: (targetId) => { + let clearedCount = 0 + set((s) => { + const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + + invalidateStaleDirectSshTargetPtyBindings: (authority) => { + let clearedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = invalidateStaleDirectSshTerminalBindings( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority + ) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + + retryDirectSshTargetPanes: (authority, now = Date.now()) => { + let retriedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = retryDirectSshTerminalPanes( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority, + now + ) + retriedCount = result.retriedCount + return result.patch ?? s + }) + return retriedCount + }, + + settleDirectSshPaneRetry: (result, now = Date.now()) => { + set((s) => { + if (!isCurrentDirectSshAuthority(s, result.authority)) { + return s + } + const history = s.directSshPaneRetryHistoryByTabId[result.tabId] + const preservesExhaustedSplitAttempt = + (result.status === 'failed' || result.status === 'timed-out') && + history != null && + directSshAuthoritiesEqual(history.authority, result.authority) && + history.attemptedAt.length >= 2 + if (preservesExhaustedSplitAttempt) { + return s + } + const settlement = settleDirectSshPaneRetryState(s, result) + if (!settlement) { + return s + } + const settledState = { ...s, ...settlement } + if (result.status !== 'failed' && result.status !== 'timed-out') { + return settledState + } + const retry = retrySettledDirectSshTerminalPane( + settledState, + resolveDirectSshTerminalKeys(settledState, result.authority.targetId), + result.authority, + result.tabId, + now + ) + return retry.patch ? { ...settledState, ...retry.patch } : settledState + }) + }, + shutdownWorktreeTerminals: async (worktreeId, opts) => { const keepIdentifiers = opts?.keepIdentifiers ?? false const shutdownReason: AgentStatusWorktreeShutdownReason = @@ -2862,25 +3370,39 @@ export const createTerminalSlice: StateCreator markCodexRestartNotices: (notices) => { if (notices.length === 0) { - return + return [] } + const noticedPtyIds: string[] = [] set((s) => { const next = { ...s.codexRestartNoticeByPtyId } const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } for (const notice of notices) { const existing = next[notice.ptyId] - const previousAccountLabel = existing?.previousAccountLabel ?? notice.previousAccountLabel + // Why one record rather than two lookups: the label and the id of the + // launch account have to come from the same source, or they can end up + // describing two different accounts. + const launch = existing ?? notice + const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. - if (previousAccountLabel === notice.nextAccountLabel) { + if ( + isSameCodexRestartNoticeAccount( + { id: launch.previousAccountId, label: launch.previousAccountLabel }, + target + ) + ) { delete next[notice.ptyId] delete nextPendingCodexPaneRestartIds[notice.ptyId] continue } next[notice.ptyId] = { - previousAccountLabel, + previousAccountLabel: launch.previousAccountLabel, nextAccountLabel: notice.nextAccountLabel, + ...(launch.previousAccountId === undefined + ? {} + : { previousAccountId: launch.previousAccountId }), + ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), // Why: a queued restart relaunches under whatever account is selected // when it runs, so a later switch does not reopen an answered prompt. ...(existing?.restartRequested ? { restartRequested: true as const } : {}), @@ -2889,16 +3411,22 @@ export const createTerminalSlice: StateCreator // so a later C reopens the prompt while adding an account or // reauthenticating the active one (both re-mark live panes with the // selection unchanged) must not resurrect it and re-mute the pane. - ...(existing?.dismissed && existing.nextAccountLabel === notice.nextAccountLabel + ...(existing?.dismissed && + isSameCodexRestartNoticeAccount( + { id: existing.nextAccountId, label: existing.nextAccountLabel }, + target + ) ? { dismissed: true as const } : {}) } + noticedPtyIds.push(notice.ptyId) } return { codexRestartNoticeByPtyId: next, pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds } }) + return noticedPtyIds }, clearCodexRestartNotice: (ptyId) => { @@ -3015,10 +3543,18 @@ export const createTerminalSlice: StateCreator const nextTabsByWorktree = detachedPtyId ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) : sourceTabsByWorktree + const directSshLedger = transferDirectSshPaneDetachLedger(s, { + detachedPtyId, + sourcePtyId: sourcePrimaryPtyId, + sourceTabId, + targetTabId, + isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority) + }) return { ptyIdsByTabId: nextPtyIdsByTabId, lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...directSshLedger, ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) } }) @@ -3377,7 +3913,7 @@ export const createTerminalSlice: StateCreator const allTabs = Object.values(tabsByWorktree).flat() const tabById = buildByIdIndex(allTabs) - return { + const hydrated: WorkspaceHydrationPatch = { activeRepoId, activeWorktreeId, activeWorkspaceKey, @@ -3422,10 +3958,19 @@ export const createTerminalSlice: StateCreator }) ) } + return options?.replaceWorkspaceKeys + ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) + : hydrated }) }, - reconnectPersistedTerminals: async (_signal) => { + reconnectPersistedTerminals: async (signal, options) => { + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } const { pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, @@ -3434,9 +3979,15 @@ export const createTerminalSlice: StateCreator tabsByWorktree, ptyIdsByTabId } = get() - const ids = pendingReconnectWorktreeIds ?? [] + const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null + const ids = (pendingReconnectWorktreeIds ?? []).filter( + (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) + ) if (ids.length === 0) { + if (options) { + return + } set({ workspaceSessionReady: true, pendingReconnectWorktreeIds: [], @@ -3458,9 +4009,10 @@ export const createTerminalSlice: StateCreator const worktree = worktreeById.get(worktreeId) const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. - const sshState = repo?.connectionId ? get().sshConnectionStates.get(repo.connectionId) : null - const sshConnected = repo?.connectionId != null && sshState?.status === 'connected' - const supportsDeferredReattach = !repo?.connectionId || sshConnected + const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null + const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null + const sshConnected = sshTargetId != null && sshState?.status === 'connected' + const supportsDeferredReattach = options ? sshConnected : !repo?.connectionId || sshConnected console.debug( `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` ) @@ -3479,7 +4031,12 @@ export const createTerminalSlice: StateCreator const tabId = tab.id const layout = terminalLayoutsByTabId[tabId] const leafPtyMap = layout?.ptyIdsByLeafId ?? {} - const tabLevelPtyId = pendingReconnectPtyIdByTabId[tabId] + const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] + const tabLevelPtyId = + options && + parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== options.directSshAuthority.targetId + ? undefined + : pendingPtyId const hasLeafMappings = Object.keys(leafPtyMap).length > 0 // Why: set the wake-hint (tab.ptyId) and live-pty map (ptyIdsByTabId) so the worktree dot goes green before the pane mounts; actual reattach happens later in pty-connection.ts. @@ -3508,13 +4065,25 @@ export const createTerminalSlice: StateCreator } // Why: deferred SSH targets haven't connected yet, so their ptyIds weren't restored above; stash session IDs in a map that survives cleanup for pty-connection.ts's deferred reconnect. - const deferredSshSessionIdsByTabId: Record = {} + const scopedTabIds = new Set( + [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => + (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deferredSshSessionIdsByTabId: Record = options + ? Object.fromEntries( + Object.entries(get().deferredSshSessionIdsByTabId).filter( + ([tabId]) => !scopedTabIds.has(tabId) + ) + ) + : {} for (const worktreeId of ids) { const worktree = worktreeById.get(worktreeId) // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) const repo = repoId ? (repoById.get(repoId) ?? null) : null - if (!repo?.connectionId) { + const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId + if (!connectionId) { continue } // Why: a repo can outlive its SSH target when the target was removed out of @@ -3523,29 +4092,50 @@ export const createTerminalSlice: StateCreator // a target it no longer lists — a stranded deferred id reads as liveness and // the orphan sweep could never remove the dead tab. Defer while the list is // still unknown so a normal cold-start reconnect isn't dropped (#9911). - if (get().sshTargetsHydrated && !get().sshTargetLabels.has(repo.connectionId)) { + if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) { continue } - const sshConnected = get().sshConnectionStates.get(repo.connectionId)?.status === 'connected' + const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected' if (sshConnected) { continue } const tabs = tabsByWorktree[worktreeId] ?? [] for (const tab of tabs) { const sessionId = pendingReconnectPtyIdByTabId[tab.id] - if (sessionId) { + if (sessionId && (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId)) { deferredSshSessionIdsByTabId[tab.id] = sessionId } } } + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } + const remainingReconnectWorktreeIds = options + ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) + : [] + const remainingReconnectTabByWorktree = options + ? Object.fromEntries( + Object.entries(pendingReconnectTabByWorktree).filter( + ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) + ) + ) + : {} + const remainingReconnectPtyIdByTabId = options + ? Object.fromEntries( + Object.entries(pendingReconnectPtyIdByTabId).filter(([tabId]) => !scopedTabIds.has(tabId)) + ) + : {} set({ ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), - workspaceSessionReady: true, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {}, + ...(options ? {} : { workspaceSessionReady: true }), + pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, + pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, deferredSshSessionIdsByTabId }) } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 4c0cfd08b29..4b7169cbe3b 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -77,6 +77,7 @@ function createUIStore(): StoreApi { rightSidebarOpen: false, rightSidebarWidth: 280, markdownTocPanelWidth: 240, + combinedDiffFileTreeWidth: 256, rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', ...createSettingsSearchState(args[0]), @@ -1228,6 +1229,16 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().markdownTocPanelWidth).toBe(200) }) + it('clamps persisted combined diff file tree widths into the supported range', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(makePersistedUI({ combinedDiffFileTreeWidth: 100 })) + expect(store.getState().combinedDiffFileTreeWidth).toBe(200) + + store.getState().hydratePersistedUI(makePersistedUI({ combinedDiffFileTreeWidth: 5_000 })) + expect(store.getState().combinedDiffFileTreeWidth).toBe(640) + }) + it('preserves right sidebar widths above the former 500px cap', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a39631edc79..cc81e3ae09b 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -101,6 +101,7 @@ import { normalizeWorkspaceStatuses } from '../../../../shared/workspace-statuses' import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' +import { clampCombinedDiffFileTreeWidth } from '../../../../shared/combined-diff-file-tree-width' import { normalizeKagiSessionLink } from '../../../../shared/browser-url' import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' @@ -2416,6 +2417,11 @@ export const createUISlice: StateCreator = (set, get) undefined, s.markdownTocPanelWidth ), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + ui.combinedDiffFileTreeWidth, + undefined, + s.combinedDiffFileTreeWidth + ), rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, rightSidebarTab: rightSidebarRoute.rightSidebarTab, rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, @@ -2579,6 +2585,8 @@ export const createUISlice: StateCreator = (set, get) 'activeNudgeId' in s.updateStatus ? (s.updateStatus.activeNudgeId ?? null) : null // Why: persist dismissal so relaunch doesn't immediately re-show the same card until a newer release. void window.api.ui.set({ dismissedUpdateVersion }).catch(console.error) + // Why: main can't otherwise tell an offered update was abandoned, which keeps a local-build session pinned and stalls background checks. + void window.api.updater.dismissAvailableUpdate().catch(console.error) // Why: only consume the nudge campaign for cards from a nudge cycle, not ordinary dismissals. if (activeNudgeId) { void window.api.updater.dismissNudge().catch(console.error) diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index d1d60ed2ed9..fee8e479709 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -23,6 +23,11 @@ import type { import type { WorktreeForceDeleteReason } from '../../../../shared/worktree-removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { + HostQualifiedDetectedWorktreeResult, + SshExecutionHostId +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { PendingWorktreeCreation, WorktreeCreationPhase @@ -39,6 +44,17 @@ export type WorktreeDeleteState = { lockReason?: string | null } +export type WorktreeFetchOptions = { + requireAuthoritative?: boolean + executionHostId?: ExecutionHostId + forceLocalOwner?: boolean +} + +export type DirectSshWorktreeFetchOptions = WorktreeFetchOptions & { + executionHostId: SshExecutionHostId + directSshAuthority: DirectSshAuthority +} + export type WorktreeMetaUpdateGuard = (worktree: Worktree | DetectedWorktree | undefined) => boolean export type WorktreeMetaUpdateOptions = { @@ -120,14 +136,13 @@ export type WorktreeSlice = { /** Startup owns the initial all-host refresh; sidebar repo-change refreshes stay gated until it finishes. */ startupWorktreeRefreshCompleted: boolean fetchDetectedWorktrees: (repoId: string) => Promise - fetchWorktrees: ( - repoId: string, - options?: { - requireAuthoritative?: boolean - executionHostId?: ExecutionHostId - forceLocalOwner?: boolean - } - ) => Promise + fetchWorktrees: { + ( + repoId: string, + options: DirectSshWorktreeFetchOptions + ): Promise + (repoId: string, options?: WorktreeFetchOptions): Promise + } fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise fetchWorktreeLineage: (options?: { forceLocalOwner?: boolean }) => Promise updateWorktreeLineage: ( diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 3894e65384b..6f994ecb206 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -18,6 +18,11 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' import { beginHugeRepoWarningProbe, clearHugeRepoWarningDismissalsForTests, @@ -58,14 +63,48 @@ function makeDetectedResult( } } +const TEST_SSH_AUTHORITY: DirectSshAuthority = { + targetId: 'ssh-1', + providerEpoch: 'provider-ssh-1' as SshProviderEpoch, + connectionGeneration: 1 +} + +function qualifyDetectedResult( + args: ListDetectedWorktreesArgs, + result: DetectedWorktreeListResult +): HostQualifiedDetectedWorktreeResult { + return { + status: result.authoritative ? 'complete' : 'non-authoritative', + providerRequestId: args.providerRequestId, + repoId: args.repoId, + authority: + args.executionHostId === LOCAL_EXECUTION_HOST_ID + ? { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID } + : { + kind: 'direct-ssh', + executionHostId: args.executionHostId, + ...args.expectedAuthority + }, + result + } +} + +const listDetectedMock = vi.fn< + ( + args: ListDetectedWorktreesArgs + ) => Promise +>(async (args) => { + const result = makeDetectedResult(args.repoId, await worktreeListMock({ repoId: args.repoId })) + return qualifyDetectedResult(args, result) +}) + const mockApi = { worktrees: { create: vi.fn(), prefetchCreateBase: vi.fn().mockResolvedValue(undefined), list: worktreeListMock, - listDetected: vi.fn(async ({ repoId }: { repoId: string }) => - makeDetectedResult(repoId, await worktreeListMock({ repoId })) - ), + listDetected: listDetectedMock, + cancelListDetected: vi.fn().mockResolvedValue(undefined), listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), @@ -102,6 +141,7 @@ globalThis.window = { api: mockApi } import { WORKTREE_REFRESH_CONCURRENCY, + acquireDirectSshDetectedWorktreeRefresh, createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, @@ -135,6 +175,19 @@ function createTestStore() { // Why: this test isolates the worktree slice, so it provides only the state surface createWorktreeSlice touches. ...createWorktreeSlice(...a), trustedOrcaHooks: {}, + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, + connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + } + ] + ]), repos: [], projectHostSetups: [], deleteProjectHostSetup: vi.fn().mockResolvedValue(null), @@ -720,7 +773,9 @@ describe('fetchWorktrees', () => { tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } } as unknown as Partial) - mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving])) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) await Promise.all([ store.getState().fetchWorktrees('repo1'), @@ -841,23 +896,23 @@ describe('fetchWorktrees', () => { let releaseSsh!: () => void const localStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseLocal = release }) - return makeDetectedResult(repoId, [localWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [localWorktree])) } ) }) const sshStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseSsh = release }) - return makeDetectedResult(repoId, [sshWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [sshWorktree])) } ) }) @@ -909,12 +964,12 @@ describe('fetchWorktrees', () => { let releaseScan!: () => void const scanStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseScan = release }) - return makeDetectedResult(repoId, [sshWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [sshWorktree])) } ) }) @@ -961,12 +1016,12 @@ describe('fetchWorktrees', () => { let releaseScan!: () => void const scanStarted = new Promise((resolve) => { mockApi.worktrees.listDetected.mockImplementationOnce( - async ({ repoId }: { repoId: string }) => { + async (args: ListDetectedWorktreesArgs) => { resolve() await new Promise((release) => { releaseScan = release }) - return makeDetectedResult(repoId, [sshWorktree]) + return qualifyDetectedResult(args, makeDetectedResult(args.repoId, [sshWorktree])) } ) }) @@ -1003,6 +1058,414 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) }) + it('exposes shared direct leases and reports cancellation only for the last waiter', async () => { + const store = createTestStore() + let request!: ListDetectedWorktreesArgs + let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void + const provider = new Promise((resolve) => { + resolveProvider = resolve + }) + mockApi.worktrees.listDetected.mockImplementationOnce( + async (args: ListDetectedWorktreesArgs) => { + request = args + return provider + } + ) + + const input = { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1' as const, + authority: TEST_SSH_AUTHORITY + } + const first = acquireDirectSshDetectedWorktreeRefresh(store, input) + const second = acquireDirectSshDetectedWorktreeRefresh(store, input) + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(first.providerRequestId).toBe(second.providerRequestId) + expect(first.waiterLeaseId).not.toBe(second.waiterLeaseId) + expect(first.release('superseded')).toBe('retained') + expect(mockApi.worktrees.cancelListDetected).not.toHaveBeenCalled() + expect(second.release('invalidated')).toBe('cancel-started') + expect(second.release('stopped')).toBe('already-settled') + expect(mockApi.worktrees.cancelListDetected).toHaveBeenCalledWith({ + providerRequestId: first.providerRequestId + }) + + resolveProvider(qualifyDetectedResult(request, makeDetectedResult('repo-ssh', []))) + await provider + await Promise.resolve() + }) + + it('merges one exact direct provider result once without a second scan', async () => { + const store = createTestStore() + const worktree = makeWorktree({ + id: 'repo-ssh::/home/orca/feature', + repoId: 'repo-ssh', + path: '/home/orca/feature' + }) + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ] + } as Partial) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktree])) + ) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + + const lease = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: TEST_SSH_AUTHORITY + }) + const providerResult = await lease.result + const firstMerge = lease.merge(providerResult) + const secondMerge = lease.merge(providerResult) + + expect(firstMerge).toBe(providerResult) + expect(secondMerge).toBe(providerResult) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(subscriber).toHaveBeenCalledTimes(1) + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ + { ...worktree, hostId: 'ssh:ssh-1' } + ]) + unsubscribe() + }) + + it('rejects a late duplicate exact-host owner with zero mutation publications', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + branch: 'refs/heads/old', + hostId: 'ssh:ssh-1' + }) + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [existing] }, + detectedWorktreesByRepo: { + 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) + } + } as Partial) + let request!: ListDetectedWorktreesArgs + let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void + mockApi.worktrees.listDetected.mockImplementationOnce( + (args: ListDetectedWorktreesArgs) => + new Promise((resolve) => { + request = args + resolveProvider = resolve + }) + ) + const lease = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: TEST_SSH_AUTHORITY + }) + + store.setState((state) => ({ + repos: [ + ...state.repos, + { + id: 'repo-ssh', + path: '/home/orca/duplicate', + displayName: 'Duplicate SSH Repo', + badgeColor: '#111', + addedAt: 1, + connectionId: 'ssh-1' + } + ] + })) + const beforeWorktrees = store.getState().worktreesByRepo + const beforeDetected = store.getState().detectedWorktreesByRepo + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + resolveProvider( + qualifyDetectedResult( + request, + makeDetectedResult('repo-ssh', [ + { + ...existing, + branch: 'refs/heads/new' + } + ]) + ) + ) + + expect(lease.merge(await lease.result)).toMatchObject({ status: 'stale' }) + expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) + expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) + expect(subscriber).not.toHaveBeenCalled() + unsubscribe() + }) + + it('fails closed before provider acquisition when direct authority is partial', async () => { + const store = createTestStore() + const worktreesByRepo = store.getState().worktreesByRepo + const detectedWorktreesByRepo = store.getState().detectedWorktreesByRepo + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch + } + ] + ]) + } as Partial) + + await expect(store.getState().fetchWorktrees('repo-ssh')).resolves.toBe(false) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo).toBe(worktreesByRepo) + expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) + }) + + it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { + const store = createTestStore() + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + hostId: 'ssh:ssh-1' + }) + store.setState({ + repos: [ + { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { 'repo-ssh': [existing] }, + detectedWorktreesByRepo: { + 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) + } + } as Partial) + const beforeWorktrees = store.getState().worktreesByRepo + const beforeDetected = store.getState().detectedWorktreesByRepo + const beforeBytes = JSON.stringify([beforeWorktrees, beforeDetected]) + let request!: ListDetectedWorktreesArgs + let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void + mockApi.worktrees.listDetected.mockImplementationOnce( + async (args: ListDetectedWorktreesArgs) => { + request = args + return new Promise((resolve) => { + resolveProvider = resolve + }) + } + ) + const lease = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: TEST_SSH_AUTHORITY + }) + const nextAuthority = { + ...TEST_SSH_AUTHORITY, + providerEpoch: 'provider-ssh-2' as SshProviderEpoch, + connectionGeneration: 2 + } + store.setState({ + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: nextAuthority.providerEpoch, + connectionGeneration: nextAuthority.connectionGeneration + } + ] + ]) + } as Partial) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + resolveProvider( + qualifyDetectedResult( + request, + makeDetectedResult('repo-ssh', [ + makeWorktree({ + id: 'repo-ssh::/home/orca/stale', + repoId: 'repo-ssh', + path: '/home/orca/stale' + }) + ]) + ) + ) + const staleResult = await lease.result + + expect(lease.merge(staleResult)).toMatchObject({ status: 'stale' }) + expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) + expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) + expect(JSON.stringify([beforeWorktrees, beforeDetected])).toBe(beforeBytes) + expect(subscriber).not.toHaveBeenCalled() + unsubscribe() + + store.setState({ + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: nextAuthority.providerEpoch, + connectionGeneration: nextAuthority.connectionGeneration + } + ] + ]) + } as Partial) + mockApi.worktrees.listDetected.mockImplementationOnce( + async (args: ListDetectedWorktreesArgs) => ({ + ...qualifyDetectedResult(args, makeDetectedResult(args.repoId, [])), + repoId: 'wrong-repo' + }) + ) + const malformed = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + authority: nextAuthority + }) + const malformedResult = await malformed.result + + expect(malformed.merge(malformedResult)).toMatchObject({ + status: 'rejected' + }) + expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) + expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) + }) + + it('keeps duplicate repo IDs isolated across direct SSH hosts', async () => { + const store = createTestStore() + const authorityA = { + targetId: 'ssh-a', + providerEpoch: 'provider-a' as SshProviderEpoch, + connectionGeneration: 1 + } + const authorityB = { + targetId: 'ssh-b', + providerEpoch: 'provider-b' as SshProviderEpoch, + connectionGeneration: 4 + } + const worktreeA = makeWorktree({ + id: 'same-repo::/srv/a', + repoId: 'same-repo', + path: '/srv/a' + }) + const worktreeB = makeWorktree({ + id: 'same-repo::/srv/b', + repoId: 'same-repo', + path: '/srv/b' + }) + store.setState({ + repos: [ + { + id: 'same-repo', + path: '/srv/repo-a', + displayName: 'Repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-a' + }, + { + id: 'same-repo', + path: '/srv/repo-b', + displayName: 'Repo B', + badgeColor: '#111', + addedAt: 1, + connectionId: 'ssh-b' + } + ], + sshConnectionStates: new Map([ + [ + 'ssh-a', + { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authorityA.providerEpoch, + connectionGeneration: authorityA.connectionGeneration + } + ], + [ + 'ssh-b', + { + targetId: 'ssh-b', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: authorityB.providerEpoch, + connectionGeneration: authorityB.connectionGeneration + } + ] + ]) + } as Partial) + mockApi.worktrees.listDetected + .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeA])) + ) + .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeB])) + ) + + const refreshA = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'same-repo', + executionHostId: 'ssh:ssh-a', + authority: authorityA + }) + const refreshB = acquireDirectSshDetectedWorktreeRefresh(store, { + repoId: 'same-repo', + executionHostId: 'ssh:ssh-b', + authority: authorityB + }) + refreshA.merge(await refreshA.result) + refreshB.merge(await refreshB.result) + + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) + expect(refreshA.providerRequestId).not.toBe(refreshB.providerRequestId) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + { ...worktreeA, hostId: 'ssh:ssh-a' }, + { ...worktreeB, hostId: 'ssh:ssh-b' } + ]) + }) + it('purges remembered right sidebar tabs for worktrees removed by a committed refresh', async () => { const store = createTestStore() const removed = makeWorktree({ @@ -1145,7 +1608,9 @@ describe('fetchWorktrees', () => { }) }) ) - mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving])) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) store.setState({ repos: [ { @@ -1238,7 +1703,9 @@ describe('fetchWorktrees', () => { path: '/path/surviving' }) - mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving])) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) store.setState({ worktreesByRepo: { repo1: [] }, detectedWorktreesByRepo: { @@ -1449,7 +1916,12 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('same-repo', { forceLocalOwner: true }) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'same-repo', + executionHostId: 'local' + }) + ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local]) expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( @@ -1481,13 +1953,19 @@ describe('fetchWorktrees', () => { } ] } as Partial) - mockApi.worktrees.listDetected.mockResolvedValueOnce( - makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' }) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => + qualifyDetectedResult(args, makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' })) ) await store.getState().fetchWorktrees('repo-ssh', { forceLocalOwner: true }) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'repo-ssh', + executionHostId: 'ssh:ssh-1', + expectedAuthority: TEST_SSH_AUTHORITY + }) + ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() // Why: SSH worktrees are fetched via local IPC but belong to the SSH host, so they carry the repo's ssh host id. expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ @@ -1529,7 +2007,12 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('same-repo', { executionHostId: 'local' }) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'same-repo', + executionHostId: 'local' + }) + ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) }) @@ -6823,7 +7306,12 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect.objectContaining({ id: refreshedRemoteWorktree.id, hostId: 'runtime:env-1' }) ]) ) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith( + expect.objectContaining({ + repoId: 'same-repo', + executionHostId: 'local' + }) + ) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'worktree.detectedList', diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 319d48fad6a..6fc54f2b858 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1,10 +1,8 @@ /* eslint-disable max-lines */ -import type { StateCreator } from 'zustand' +import type { StateCreator, StoreApi } from 'zustand' import type { AppState } from '../types' import type { DetectedWorktreeListResult, - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, LocalBaseRefRefreshResult, ForceDeleteWorktreeBranchResult, FolderWorkspace, @@ -23,6 +21,8 @@ import { findWorktreeById, applyWorktreeUpdates, getRepoIdFromWorktreeId, + type DirectSshWorktreeFetchOptions, + type WorktreeFetchOptions, type WorktreeSlice } from './worktree-helpers' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' @@ -101,6 +101,18 @@ import { isLockedWorktreeRemovalError } from '../../../../shared/worktree-removal' import { FolderWorkspaceActivityPersistence } from './folder-workspace-activity-persistence' +import { + createDetectedWorktreeRefreshLeaseRegistry, + type DetectedWorktreeRefreshLease +} from './detected-worktree-refresh-leases' +import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import type { + HostQualifiedDetectedWorktreeResult, + ListDetectedWorktreesArgs, + ProviderRequestId, + SshExecutionHostId +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. @@ -117,7 +129,10 @@ const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() -const detectedWorktreeRefreshesInFlight = new Map>() +const runtimeDetectedWorktreeRefreshesInFlight = new Map< + string, + Promise +>() type WorktreeSliceGet = Parameters>[1] const folderWorkspaceActivityPersistenceByStore = new WeakMap< WorktreeSliceGet, @@ -144,6 +159,38 @@ type BackgroundRuntimeRefreshOptions = { reuseRecentCompatibilityFailure?: boolean } +type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & { + executionHostId: ExecutionHostId + requireAuthoritative?: boolean + directSshAuthority?: DirectSshAuthority + // Why (#10562): the caller's own view of what it is about to purge. Teardown is + // requested per caller, so this must never be shared with a coalesced scan. + connectionId?: string | null + knownWorktreeIds?: readonly string[] +} + +type AdmittedDetectedWorktreeRefresh = { + status: 'admitted' + result: DetectedWorktreeListResult + providerResult?: HostQualifiedDetectedWorktreeResult + executionHostId: ExecutionHostId + directSshAuthority?: DirectSshAuthority + runtimeAuthority?: { + environmentId: string + connectionGeneration: number + runtimeConnectionGeneration: number + } +} + +type DetectedWorktreeRefreshOutcome = + | AdmittedDetectedWorktreeRefresh + | { + status: 'not-admitted' + providerResult: HostQualifiedDetectedWorktreeResult + executionHostId: ExecutionHostId + directSshAuthority?: DirectSshAuthority + } + async function mapReposForWorktreeRefresh( repos: readonly TRepo[], mapper: (repo: TRepo) => Promise @@ -166,25 +213,6 @@ async function mapReposForWorktreeRefresh return results } -function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { - if (!node) { - return 0 - } - if (node.type === 'leaf') { - return 1 - } - return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second) -} - -function getActivationSpawnSuppression(layout: TerminalLayoutSnapshot | undefined): true | number { - const paneCount = Math.max( - 1, - countTerminalLayoutLeaves(layout?.root), - Object.keys(layout?.ptyIdsByLeafId ?? {}).length - ) - return paneCount === 1 ? true : paneCount -} - function shouldDeferActivationTerminalPrep(): boolean { return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' } @@ -408,17 +436,39 @@ function repoHostId( return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } -function repoHasExecutionHost( +function repoHasExactlyOneExecutionHostOwner( state: Pick, repoId: string, hostId: ExecutionHostId, ownerWasMissingAtStart: boolean ): boolean { const repoOwners = state.repos.filter((repo) => repo.id === repoId) - // Why: worktrees can load before the repo catalog at startup; only reject a missing owner when this request previously observed an owned repo. + if (repoOwners.length === 0) { + return ownerWasMissingAtStart + } + const ownerHostIds = repoOwners.map((repo) => { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return null + } + const rawConnectionId = repo.connectionId + const hasConnection = rawConnectionId !== null && rawConnectionId !== undefined + const connectionId = hasConnection ? rawConnectionId.trim() : null + if (hasConnection && !connectionId) { + return null + } + if (!connectionId || explicitHost?.kind === 'runtime') { + return explicitHost?.id ?? LOCAL_EXECUTION_HOST_ID + } + if (explicitHost && explicitHost.id !== toSshExecutionHostId(connectionId)) { + return null + } + return explicitHost?.id ?? toSshExecutionHostId(connectionId) + }) return ( - (repoOwners.length === 0 && ownerWasMissingAtStart) || - repoOwners.some((repo) => getRepoExecutionHostId(repo) === hostId) + ownerHostIds.every((ownerHostId) => ownerHostId !== null) && + ownerHostIds.filter((ownerHostId) => ownerHostId === hostId).length === 1 ) } @@ -642,10 +692,12 @@ async function teardownMissingWorktreeTerminalsBestEffort( settings: AppState['settings'], repoId: string, connectionId: string | null | undefined, - knownWorktreeIds: readonly string[], + // Why: refreshes that never purge omit this; an absent snapshot means + // "nothing to reconcile", never "purge everything". + knownWorktreeIds: readonly string[] | undefined, detected: DetectedWorktreeListResult ): Promise { - if (!detected.authoritative || knownWorktreeIds.length === 0) { + if (!detected.authoritative || !knownWorktreeIds || knownWorktreeIds.length === 0) { return } const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) @@ -1018,14 +1070,7 @@ async function listDetectedWorktreesForRepo( ): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { - const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { - listDetected?: typeof window.api.worktrees.listDetected - } - if (typeof worktreesApi.listDetected === 'function') { - return worktreesApi.listDetected({ repoId }) - } - const legacyWorktrees = await worktreesApi.list({ repoId }) - return toLegacyDetectedWorktreeResult(repoId, { worktrees: legacyWorktrees }) + throw new Error('Local detected-worktree reads require a provider lease') } try { return await callRuntimeRpc( @@ -1057,13 +1102,7 @@ async function listDetectedWorktreesForRepo( function detectedWorktreeRefreshKey( settings: AppState['settings'], repoId: string, - options: { - executionHostId: ExecutionHostId - connectionId?: string | null - knownWorktreeIds: readonly string[] - requireAuthoritative?: boolean - reuseRecentCompatibilityFailure?: boolean - } + options: DetectedWorktreeRefreshOptions ): string { const target = getActiveRuntimeTarget(settings) const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` @@ -1082,61 +1121,263 @@ function detectedWorktreeRefreshKey( return parts.join('\n') } +function isDetectedWorktreeListResult(value: unknown): value is DetectedWorktreeListResult { + if (!value || typeof value !== 'object') { + return false + } + const result = value as Partial + return ( + typeof result.repoId === 'string' && + typeof result.authoritative === 'boolean' && + (result.source === 'git' || + result.source === 'metadata-fallback' || + result.source === 'session-fallback') && + Array.isArray(result.worktrees) + ) +} + +function rejectedDetectedWorktreeProviderResult( + request: ListDetectedWorktreesArgs +): HostQualifiedDetectedWorktreeResult { + return { + providerRequestId: request.providerRequestId, + executionHostId: request.executionHostId, + status: 'rejected' + } +} + +async function startDetectedWorktreeProviderRequest( + request: ListDetectedWorktreesArgs +): Promise { + const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { + listDetected?: typeof window.api.worktrees.listDetected + } + if (typeof worktreesApi.listDetected !== 'function') { + if (request.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return rejectedDetectedWorktreeProviderResult(request) + } + const worktrees = await worktreesApi.list({ repoId: request.repoId }) + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result: toLegacyDetectedWorktreeResult(request.repoId, { worktrees }) + } + } + const result = await worktreesApi.listDetected(request) + if (result && typeof result === 'object' && 'status' in result && 'providerRequestId' in result) { + return result as unknown as HostQualifiedDetectedWorktreeResult + } + // Why: web and older preload implementations return the legacy local shape. + if (request.executionHostId === LOCAL_EXECUTION_HOST_ID && isDetectedWorktreeListResult(result)) { + return { + status: result.authoritative ? 'complete' : 'non-authoritative', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + return rejectedDetectedWorktreeProviderResult(request) +} + +const detectedWorktreeRefreshLeaseRegistry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: startDetectedWorktreeProviderRequest, + cancelProviderRequest: async (request) => { + await window.api.worktrees.cancelListDetected?.({ + providerRequestId: request.providerRequestId + }) + } +}) + +export function acquireDetectedWorktreeRefreshLeaseForRepo( + settings: AppState['settings'], + repoId: string, + options: DetectedWorktreeRefreshOptions +): DetectedWorktreeRefreshLease { + const parsedHost = parseExecutionHostId(options.executionHostId) + if (!parsedHost || parsedHost.kind === 'runtime') { + throw new Error('Provider leases require a local or direct SSH execution host') + } + const publicKey = detectedWorktreeRefreshKey(settings, repoId, options) + if (parsedHost.kind === 'local') { + return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { + repoId, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + } + if ( + !options.directSshAuthority || + !directSshAuthorityIsComplete(options.directSshAuthority, parsedHost.targetId) + ) { + throw new Error('Direct SSH provider leases require exact target authority') + } + return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { + repoId, + executionHostId: options.executionHostId as SshExecutionHostId, + expectedAuthority: { ...options.directSshAuthority } + }) +} + +function qualifiedProviderResultIsAdmitted( + result: HostQualifiedDetectedWorktreeResult, + providerRequestId: ProviderRequestId, + repoId: string, + options: DetectedWorktreeRefreshOptions +): result is Extract< + HostQualifiedDetectedWorktreeResult, + { status: 'complete' | 'non-authoritative' } +> { + if ( + result.providerRequestId !== providerRequestId || + (result.status !== 'complete' && result.status !== 'non-authoritative') || + !isDetectedWorktreeListResult(result.result) || + !result.authority || + result.repoId !== repoId || + result.result.repoId !== repoId || + result.result.authoritative !== (result.status === 'complete') + ) { + return false + } + const parsedHost = parseExecutionHostId(options.executionHostId) + if (parsedHost?.kind === 'local') { + return ( + result.authority.kind === 'local' && + result.authority.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + } + const expected = options.directSshAuthority + return ( + parsedHost?.kind === 'ssh' && + expected !== undefined && + result.authority.kind === 'direct-ssh' && + result.authority.executionHostId === options.executionHostId && + result.authority.targetId === expected.targetId && + result.authority.providerEpoch === expected.providerEpoch && + result.authority.connectionGeneration === expected.connectionGeneration + ) +} + +function normalizeNotAdmittedProviderResult( + result: HostQualifiedDetectedWorktreeResult, + providerRequestId: ProviderRequestId, + executionHostId: ExecutionHostId +): HostQualifiedDetectedWorktreeResult { + if ( + result.providerRequestId === providerRequestId && + result.status !== 'complete' && + result.status !== 'non-authoritative' && + 'executionHostId' in result && + result.executionHostId === executionHostId + ) { + return result + } + return { + providerRequestId, + executionHostId, + status: 'rejected' + } +} + async function listDetectedWorktreesForRepoCoalesced( settings: AppState['settings'], repoId: string, - options: { - executionHostId: ExecutionHostId - connectionId?: string | null - knownWorktreeIds: readonly string[] - requireAuthoritative?: boolean - reuseRecentCompatibilityFailure?: boolean - } -): Promise { + options: DetectedWorktreeRefreshOptions +): Promise { const key = detectedWorktreeRefreshKey(settings, repoId, options) const target = getActiveRuntimeTarget(settings) - const connectionGeneration = - target.kind === 'environment' ? getEnvironmentSshStateGeneration(target.environmentId) : null - const runtimeConnectionGeneration = - target.kind === 'environment' - ? getRuntimeEnvironmentConnectionGeneration(target.environmentId) - : null - const existing = detectedWorktreeRefreshesInFlight.get(key) - // Why: the scan coalesces, but teardown must not — each caller carries its own - // known-id snapshot and purges its own state, so a caller that joined an - // in-flight scan would otherwise purge without ever stopping those terminals. - const result = existing ? await existing : await runCoalescedDetectedWorktreeScan() + if (target.kind === 'environment') { + const connectionGeneration = getEnvironmentSshStateGeneration(target.environmentId) + const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration( + target.environmentId + ) + let refresh = runtimeDetectedWorktreeRefreshesInFlight.get(key) + if (!refresh) { + refresh = listDetectedWorktreesForRepo(settings, repoId, { + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + }) + runtimeDetectedWorktreeRefreshesInFlight.set(key, refresh) + } + try { + const result = await refresh + if ( + getEnvironmentSshStateGeneration(target.environmentId) !== connectionGeneration || + getRuntimeEnvironmentConnectionGeneration(target.environmentId) !== + runtimeConnectionGeneration + ) { + throw new Error('runtime_environment_generation_changed') + } + // Why (#10562): the scan coalesces, but teardown must not — each caller carries + // its own known-id snapshot and purges its own state, so a caller that joined + // an in-flight scan would otherwise purge without ever stopping those terminals. + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + result + ) + return { + status: 'admitted', + result, + executionHostId: options.executionHostId, + runtimeAuthority: { + environmentId: target.environmentId, + connectionGeneration, + runtimeConnectionGeneration + } + } + } finally { + if (runtimeDetectedWorktreeRefreshesInFlight.get(key) === refresh) { + runtimeDetectedWorktreeRefreshesInFlight.delete(key) + } + } + } + + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, repoId, options) + let providerResult: HostQualifiedDetectedWorktreeResult + try { + providerResult = await lease.result + } catch { + return { + status: 'not-admitted', + providerResult: { + providerRequestId: lease.providerRequestId, + executionHostId: options.executionHostId, + status: 'rejected' + }, + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } + } + if ( + !qualifiedProviderResultIsAdmitted(providerResult, lease.providerRequestId, repoId, options) + ) { + return { + status: 'not-admitted', + providerResult: normalizeNotAdmittedProviderResult( + providerResult, + lease.providerRequestId, + options.executionHostId + ), + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } + } await teardownMissingWorktreeTerminalsBestEffort( settings, repoId, options.connectionId, options.knownWorktreeIds, - result + providerResult.result ) - return result - - async function runCoalescedDetectedWorktreeScan(): Promise { - // Why: startup/event fan-out can request the same repo/host refresh many times at once; share only the scan promise so state-merge semantics stay local. - const refresh = listDetectedWorktreesForRepo(settings, repoId, { - reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure - }) - detectedWorktreeRefreshesInFlight.set(key, refresh) - try { - const scanned = await refresh - if ( - target.kind === 'environment' && - (getEnvironmentSshStateGeneration(target.environmentId) !== connectionGeneration || - getRuntimeEnvironmentConnectionGeneration(target.environmentId) !== - runtimeConnectionGeneration) - ) { - throw new Error('runtime_environment_generation_changed') - } - return scanned - } finally { - if (detectedWorktreeRefreshesInFlight.get(key) === refresh) { - detectedWorktreeRefreshesInFlight.delete(key) - } - } + return { + status: 'admitted', + result: providerResult.result, + providerResult, + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority } } @@ -2202,6 +2443,22 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial !worktreeIdSet.has(worktreeId)) + .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) + ) + const omitRetiredDirectSshLedgerByTabId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const tabId of doomedTabIds) { + if (!survivingTabIds.has(tabId) && tabId in out) { + delete out[tabId] + changed = true + } + } + return changed ? out : obj + } const omitByPtyId = (obj: Record): Record => { let changed = false const out = { ...obj } @@ -2301,6 +2558,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial, + hostId: ExecutionHostId +): DirectSshAuthority | null { + const parsedHost = parseExecutionHostId(hostId) + if (parsedHost?.kind !== 'ssh') { + return null + } + const connection = state.sshConnectionStates?.get(parsedHost.targetId) + if (connection?.status !== 'connected') { + return null + } + const authority = { + targetId: parsedHost.targetId, + providerEpoch: connection.providerEpoch, + connectionGeneration: connection.connectionGeneration + } as DirectSshAuthority + if (!directSshAuthorityIsComplete(authority, parsedHost.targetId)) { + return null + } + return { + ...authority + } +} + +function directSshAuthoritiesEqual( + left: DirectSshAuthority | null | undefined, + right: DirectSshAuthority | null | undefined +): boolean { + if (!left || !right) { + return false + } + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +function isCurrentDetectedWorktreeRefresh( + state: Pick, + refresh: AdmittedDetectedWorktreeRefresh +): boolean { + if (refresh.directSshAuthority) { + return directSshAuthoritiesEqual( + getCurrentDirectSshAuthority(state, refresh.executionHostId), + refresh.directSshAuthority + ) + } + if (refresh.runtimeAuthority) { + return ( + getEnvironmentSshStateGeneration(refresh.runtimeAuthority.environmentId) === + refresh.runtimeAuthority.connectionGeneration && + getRuntimeEnvironmentConnectionGeneration(refresh.runtimeAuthority.environmentId) === + refresh.runtimeAuthority.runtimeConnectionGeneration + ) + } + return true +} + +function staleDetectedWorktreeProviderResult( + refresh: AdmittedDetectedWorktreeRefresh +): HostQualifiedDetectedWorktreeResult | undefined { + return refresh.providerResult + ? { + providerRequestId: refresh.providerResult.providerRequestId, + executionHostId: refresh.executionHostId, + status: 'stale' + } + : undefined +} + +type FencedWorktreeMergeArgs = { + repoId: string + hostId: ExecutionHostId + ownerWasMissingAtStart: boolean + requestStartedWorktrees: readonly Worktree[] | undefined + setup?: ProjectHostSetup + refresh: AdmittedDetectedWorktreeRefresh + purgeRemovedWorktrees?: boolean +} + +function mergeFetchedWorktrees( + set: Parameters>[0], + args: FencedWorktreeMergeArgs +): boolean { + let admitted = false + set((s) => { + if ( + !isCurrentDetectedWorktreeRefresh(s, args.refresh) || + !repoHasExactlyOneExecutionHostOwner( + s, + args.repoId, + args.hostId, + args.ownerWasMissingAtStart && !args.refresh.directSshAuthority + ) + ) { + return s + } + admitted = true + const matchOptions = worktreeHostMatchOptions(s, args.repoId, args.hostId) + let incoming = toVisibleWorktrees(args.refresh.result, args.hostId, args.setup) + incoming = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: args.requestStartedWorktrees, + current: s.worktreesByRepo[args.repoId], + incoming, + matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions), + hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, + updateWorktreeGitIdentity: s.updateWorktreeGitIdentity + }) + const worktrees = sanitizeHostedReviewLinksForBranchClears( + incoming, + s.worktreesByRepo[args.repoId] + ) + const currentForHost = (s.worktreesByRepo[args.repoId] ?? []).filter((worktree) => + worktreeMatchesHost(worktree, args.hostId, matchOptions) + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[args.repoId], + args.refresh.result, + args.hostId, + args.setup, + matchOptions + ) + if (!args.refresh.result.authoritative && worktrees.length === 0 && currentForHost.length > 0) { + return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[args.repoId], mergedDetected) + ? s + : { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [args.repoId]: mergedDetected + } + } + } + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[args.repoId], + worktrees, + args.hostId, + matchOptions + ) + const removedIds = + args.purgeRemovedWorktrees === false + ? [] + : getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + args.repoId, + args.refresh.result, + args.hostId + ) + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + s.detectedWorktreesByRepo[args.repoId], + mergedDetected + ) + if (!worktreesChanged && !detectedChanged && removedIds.length === 0) { + return s + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { + ...s.worktreesByRepo, + [args.repoId]: mergedWorktrees + }, + sortEpoch: s.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [args.repoId]: mergedDetected + } + } + : {}), + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + return admitted +} + +export type DirectSshDetectedWorktreeRefresh = { + waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] + providerRequestId: ProviderRequestId + result: Promise + release: DetectedWorktreeRefreshLease['release'] + merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult +} + +export function acquireDirectSshDetectedWorktreeRefresh( + store: Pick, 'getState' | 'setState'>, + request: { + repoId: string + executionHostId: SshExecutionHostId + authority: DirectSshAuthority + requireAuthoritative?: boolean + } +): DirectSshDetectedWorktreeRefresh { + const requestStartedState = store.getState() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[request.repoId] + const ownerWasMissingAtStart = !requestStartedState.repos.some( + (repo) => repo.id === request.repoId + ) + const setup = getProjectHostSetupForRepoHost( + requestStartedState, + request.repoId, + request.executionHostId + ) + const settings = settingsForRepoOwner( + requestStartedState, + request.repoId, + request.executionHostId + ) + const options: DetectedWorktreeRefreshOptions = { + executionHostId: request.executionHostId, + directSshAuthority: request.authority, + requireAuthoritative: request.requireAuthoritative + } + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, request.repoId, options) + let mergedResult: HostQualifiedDetectedWorktreeResult | undefined + + return { + waiterLeaseId: lease.waiterLeaseId, + providerRequestId: lease.providerRequestId, + result: lease.result, + release: lease.release, + merge: (providerResult) => { + if (mergedResult) { + return mergedResult + } + if ( + !qualifiedProviderResultIsAdmitted( + providerResult, + lease.providerRequestId, + request.repoId, + options + ) + ) { + mergedResult = normalizeNotAdmittedProviderResult( + providerResult, + lease.providerRequestId, + request.executionHostId + ) + return mergedResult + } + if (request.requireAuthoritative && providerResult.status !== 'complete') { + mergedResult = providerResult + return mergedResult + } + const refresh: AdmittedDetectedWorktreeRefresh = { + status: 'admitted', + result: providerResult.result, + providerResult, + executionHostId: request.executionHostId, + directSshAuthority: request.authority + } + const admitted = mergeFetchedWorktrees( + store.setState as Parameters>[0], + { + repoId: request.repoId, + hostId: request.executionHostId, + ownerWasMissingAtStart, + requestStartedWorktrees, + setup, + refresh + } + ) + mergedResult = admitted + ? providerResult + : (staleDetectedWorktreeProviderResult(refresh) ?? providerResult) + return mergedResult + } + } +} + export const createWorktreeSlice: StateCreator = (set, get) => ({ worktreesByRepo: {}, detectedWorktreesByRepo: {}, @@ -2449,32 +3005,59 @@ export const createWorktreeSlice: StateCreator hostId, settings: ownerState.settings }) - const result = await listDetectedWorktreesForRepoCoalesced( + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return null + } + const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForRepoOwner(ownerState, repoId, hostId), repoId, { executionHostId: hostId, + directSshAuthority, connectionId: repoOwner?.connectionId, knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) } ) + if (refresh.status !== 'admitted') { + return null + } + let admitted = false set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { + if ( + !isCurrentDetectedWorktreeRefresh(s, refresh) || + !repoHasExactlyOneExecutionHostOwner( + s, + repoId, + hostId, + ownerWasMissingAtStart && !refresh.directSshAuthority + ) + ) { return s } + admitted = true // Why: detected-only refreshes can overlap host-scoped visible refreshes; merge detected state so SSH/runtime rows aren't clobbered. const mergedDetected = mergeDetectedWorktreesForHost( s.detectedWorktreesByRepo[repoId], - result, + refresh.result, hostId, setup, worktreeHostMatchOptions(s, repoId, hostId) ) return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) ? s - : { detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected } } + : { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + } + } }) - return result + return admitted ? refresh.result : null } catch (err) { if (notifyRuntimeScopeForbiddenIfNeeded(err)) { return null @@ -2484,11 +3067,17 @@ export const createWorktreeSlice: StateCreator } }, - fetchWorktrees: async (repoId, options) => { + fetchWorktrees: (async ( + repoId: string, + options?: WorktreeFetchOptions | DirectSshWorktreeFetchOptions + ) => { + const directCallerAuthority = + options && 'directSshAuthority' in options ? options.directSshAuthority : undefined try { const ownerState = get() const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId) + const ownerWasMissingAtStart = repoOwners.length === 0 const hasLocalOwner = repoOwners.some( (repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID ) @@ -2499,7 +3088,6 @@ export const createWorktreeSlice: StateCreator const hostId = useLocalOwner ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId, options?.executionHostId) - const ownerWasMissingAtStart = repoOwners.length === 0 const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) const repoOwner = findRepoForHost(ownerState.repos, repoId, { hostId, @@ -2510,137 +3098,45 @@ export const createWorktreeSlice: StateCreator useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId ? { ...ownerSettings, activeRuntimeEnvironmentId: null } : ownerSettings - const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return false + } + const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { executionHostId: hostId, + requireAuthoritative: options?.requireAuthoritative, + directSshAuthority, connectionId: repoOwner?.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId), - requireAuthoritative: options?.requireAuthoritative + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) }) - if (options?.requireAuthoritative && !detected.authoritative) { - return false + if (refresh.status !== 'admitted') { + return directCallerAuthority ? refresh.providerResult : false } - let incoming = toVisibleWorktrees(detected, hostId, setup) - const latestState = get() - if (repoHasExecutionHost(latestState, repoId, hostId, ownerWasMissingAtStart)) { - const matchOptions = worktreeHostMatchOptions(latestState, repoId, hostId) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: requestStartedWorktrees, - current: latestState.worktreesByRepo[repoId], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity - }) + if (options?.requireAuthoritative && !refresh.result.authoritative) { + return directCallerAuthority ? refresh.providerResult : false } - const current = get().worktreesByRepo[repoId] - const worktrees = sanitizeHostedReviewLinksForBranchClears(incoming, current) - const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) - const currentForHost = (current ?? []).filter((worktree) => - worktreeMatchesHost(worktree, hostId, currentMatchOptions) - ) - if (areWorktreesEqual(currentForHost, worktrees)) { - set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { - return s - } - const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( - s, - repoId, - detected, - hostId - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - matchOptions - ) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[repoId], - worktrees, - hostId, - matchOptions - ) - const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[repoId], mergedWorktrees) - if ( - !worktreesChanged && - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) && - removedIds.length === 0 - ) { - return s - } - return { - worktreesByRepo: { - ...s.worktreesByRepo, - [repoId]: mergedWorktrees - }, - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [repoId]: mergedDetected - }, - ...(worktreesChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } - }) + const admitted = mergeFetchedWorktrees(set, { + repoId, + hostId, + ownerWasMissingAtStart, + requestStartedWorktrees, + setup, + refresh + }) + if (!admitted) { + return directCallerAuthority + ? (staleDetectedWorktreeProviderResult(refresh) ?? false) + : false + } + // Direct SSH lineage requires its own qualified authority result. + if (!directSshAuthority) { await refreshRemoteWorktreeLineageBestEffort(settings, set) - return detected.authoritative } - - // Why: a transient `git worktree list` failure returns []; replacing a known-good list with [] orphans tabsByWorktree state, so keep stale-but-correct data until the next successful refresh. - if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { - set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { - return s - } - return { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [repoId]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - worktreeHostMatchOptions(s, repoId, hostId) - ) - } - } - }) - return false - } - - set((s) => { - if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) { - return s - } - // Why: hidden worktrees aren't in worktreesByRepo; diff purges against the prior authoritative detected list so hiding keeps state but deleting a hidden worktree still purges. - const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected, hostId) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[repoId], - worktrees, - hostId, - matchOptions - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - detected, - hostId, - setup, - matchOptions - ) - - return { - // Why: a terminal can switch an active worktree's branch; refresh that live git identity but only bump sortEpoch when the payload actually changed. - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: mergedWorktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected }, - sortEpoch: s.sortEpoch + 1, - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } - }) - await refreshRemoteWorktreeLineageBestEffort(settings, set) - return detected.authoritative + return directCallerAuthority ? refresh.providerResult! : refresh.result.authoritative } catch (err) { if (notifyRuntimeScopeForbiddenIfNeeded(err)) { return false @@ -2648,7 +3144,7 @@ export const createWorktreeSlice: StateCreator console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) return false } - }, + }) as WorktreeSlice['fetchWorktrees'], fetchAllWorktrees: async (options) => { const { repos } = get() @@ -2662,66 +3158,31 @@ export const createWorktreeSlice: StateCreator const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) - const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { - executionHostId: hostId, - connectionId: r.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId), - reuseRecentCompatibilityFailure: true - }) - let incoming = toVisibleWorktrees(detected, hostId, setup) - const latestState = get() - if (repoHasExecutionHost(latestState, r.id, hostId, false)) { - const matchOptions = worktreeHostMatchOptions(latestState, r.id, hostId) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: requestStartedWorktrees, - current: latestState.worktreesByRepo[r.id], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity - }) + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return } - const worktrees = sanitizeHostedReviewLinksForBranchClears( - incoming, - get().worktreesByRepo[r.id] - ) - set((s) => { - if (!repoHasExecutionHost(s, r.id, hostId, false)) { - return s - } - const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( - s, - r.id, - detected, - hostId - ) - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[r.id], - worktrees, - hostId, - matchOptions - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - matchOptions - ) - if ( - areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && - removedIds.length === 0 - ) { - return s - } - return { - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, - sortEpoch: s.sortEpoch + 1, - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } + const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) + }) + if (refresh.status !== 'admitted') { + return + } + mergeFetchedWorktrees(set, { + repoId: r.id, + hostId, + ownerWasMissingAtStart: false, + requestStartedWorktrees, + setup, + refresh }) } catch (err) { if (notifyRuntimeScopeForbiddenIfNeeded(err)) { @@ -2748,82 +3209,45 @@ export const createWorktreeSlice: StateCreator const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) - const detected = await listDetectedWorktreesForRepoCoalesced( + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + return { repoId: r.id, ok: false as const } + } + const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForKnownRepoOwner(requestStartedState.settings, r), r.id, { executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority, connectionId: r.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId), - reuseRecentCompatibilityFailure: true + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) } ) - let incoming = toVisibleWorktrees(detected, hostId, setup) - const latestState = get() - if (repoHasExecutionHost(latestState, r.id, hostId, false)) { - const matchOptions = worktreeHostMatchOptions(latestState, r.id, hostId) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: requestStartedWorktrees, - current: latestState.worktreesByRepo[r.id], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: latestState.updateWorktreeGitIdentity - }) + if (refresh.status !== 'admitted') { + return { repoId: r.id, ok: false as const } } - const current = get().worktreesByRepo[r.id] - const list = sanitizeHostedReviewLinksForBranchClears(incoming, current) - const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) - const currentForHost = (current ?? []).filter((worktree) => - worktreeMatchesHost(worktree, hostId, currentMatchOptions) - ) - if ( - !areWorktreesEqual(currentForHost, list) && - !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) - ) { - set((s) => { - if (!repoHasExecutionHost(s, r.id, hostId, false)) { - return s - } - const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) - return { - worktreesByRepo: { - ...s.worktreesByRepo, - [r.id]: mergeWorktreesForHost(s.worktreesByRepo[r.id], list, hostId, matchOptions) - }, - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [r.id]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - matchOptions - ) - }, - sortEpoch: s.sortEpoch + 1 - } - }) - } else { - set((s) => { - if (!repoHasExecutionHost(s, r.id, hostId, false)) { - return s - } - return { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [r.id]: mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[r.id], - detected, - hostId, - setup, - worktreeHostMatchOptions(s, r.id, hostId) - ) - } - } - }) + const admitted = mergeFetchedWorktrees(set, { + repoId: r.id, + hostId, + ownerWasMissingAtStart: false, + requestStartedWorktrees, + setup, + refresh, + purgeRemovedWorktrees: false + }) + if (!admitted) { + return { repoId: r.id, ok: false as const } + } + return { + repoId: r.id, + ok: refresh.result.authoritative, + detected: refresh.result } - return { repoId: r.id, ok: detected.authoritative, detected } } catch (err) { console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) return { repoId: r.id, ok: false as const } @@ -3538,6 +3962,7 @@ export const createWorktreeSlice: StateCreator ...s.automaticAgentResumeClaimsByTabId } const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId } const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId } @@ -3547,6 +3972,7 @@ export const createWorktreeSlice: StateCreator delete nextRuntimePaneTitlesByTabId[tabId] delete nextAutomaticAgentResumeClaimsByTabId[tabId] delete nextNativeChatLaunchPromptByTabId[tabId] + delete nextNativeChatLaunchDraftByTabId[tabId] delete nextExpandedPaneByTabId[tabId] delete nextCanExpandPaneByTabId[tabId] } @@ -3678,6 +4104,7 @@ export const createWorktreeSlice: StateCreator runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, terminalLayoutsByTabId: nextLayouts, expandedPaneByTabId: nextExpandedPaneByTabId, canExpandPaneByTabId: nextCanExpandPaneByTabId, @@ -4624,7 +5051,9 @@ export const createWorktreeSlice: StateCreator // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. generation: (tab.generation ?? 0) + 1, // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. - pendingActivationSpawn: getActivationSpawnSuppression(s.terminalLayoutsByTabId[tab.id]) + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) } remounted = true return { @@ -4817,7 +5246,7 @@ export const createWorktreeSlice: StateCreator [worktreeId]: tabs.map((tab) => ({ ...tab, generation: (tab.generation ?? 0) + 1, - pendingActivationSpawn: getActivationSpawnSuppression( + pendingActivationSpawn: getTerminalActivationSpawnSuppression( s.terminalLayoutsByTabId[tab.id] ) })) @@ -4896,7 +5325,7 @@ export const createWorktreeSlice: StateCreator ...tab, ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), // Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent. - pendingActivationSpawn: getActivationSpawnSuppression( + pendingActivationSpawn: getTerminalActivationSpawnSuppression( s.terminalLayoutsByTabId[tab.id] ) })) diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 105071637ca..cfe578f7b06 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2657,6 +2657,57 @@ describe('web worktree preload API', () => { ]) }) + it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'detected-list', + ok: true, + result: { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [ + { id: 'repo-1::/srv/repo', repoId: 'repo-1', path: '/srv/repo', hostId: 'local' }, + { + id: 'repo-1::/ssh/repo', + repoId: 'repo-1', + path: '/ssh/repo', + hostId: 'ssh:hub-private-target' + } + ] + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-env-1') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.worktrees.listDetected({ repoId: 'repo-1' }) + ).resolves.toMatchObject({ + repoId: 'repo-1', + authoritative: true, + worktrees: [ + { + hostId: 'local', + runtimeOwnerEnvironmentId: 'web-env-1' + }, + { + hostId: 'ssh:hub-private-target', + runtimeOwnerEnvironmentId: 'web-env-1' + } + ] + }) + }) + it('falls back to legacy worktree.list when detectedList is unavailable', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] const worktree = { @@ -2936,6 +2987,71 @@ describe('web worktree preload API', () => { }) }) +describe('web SSH preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('preserves full and partial authority states from the paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + const state = + method === 'ssh.connect' + ? { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'web-provider-epoch', + connectionGeneration: 23 + } + : { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'partial-provider-epoch' + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { state }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ssh.connect({ targetId: 'ssh-1' })).resolves.toMatchObject({ + providerEpoch: 'web-provider-epoch', + connectionGeneration: 23 + }) + const partial = await globals.window.api.ssh.getState({ targetId: 'ssh-1' }) + + expect(partial).toMatchObject({ providerEpoch: 'partial-provider-epoch' }) + expect(partial).not.toHaveProperty('connectionGeneration') + expect(runtimeCalls).toEqual([ + { method: 'ssh.connect', params: { targetId: 'ssh-1' } }, + { method: 'ssh.getState', params: { targetId: 'ssh-1' } } + ]) + }) +}) + describe('web file preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index adf36b1919b..4c30a983555 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -783,6 +783,7 @@ function createWebPreloadApi(): Partial { claudeAccounts: createAccountsApi(), cli: createCliApi(), agentHooks: createAgentHooksApi(), + macosTccPrompts: createMacosTccPromptsApi(), // Why: the desktop derives this from the host filesystem, which the web // client has no view of; reporting synced keeps the warning banner silent. codexConfigSync: { @@ -1064,7 +1065,7 @@ function writeWebKeybindingAction( bindings: string[] | null ): KeybindingFileSnapshot { if (!isKeybindingActionId(actionId)) { - throw new Error(`Unknown keybinding action "${actionId}".`) + throw new Error(`Unknown keybinding action "${String(actionId)}".`) } const normalizedBindings = bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) @@ -2563,6 +2564,8 @@ function createWebUiApi(): NonNullable['ui']> { onZoomBrowserPage: () => noopUnsubscribe, onHardReloadBrowserPage: () => noopUnsubscribe, onCloseActiveTab: () => noopUnsubscribe, + onCloseFloatingItem: () => noopUnsubscribe, + onSelectFloatingIndex: () => noopUnsubscribe, onSwitchTab: () => noopUnsubscribe, onSwitchTabAcrossAllTypes: () => noopUnsubscribe, onSwitchRecentTab: () => noopUnsubscribe, @@ -2599,7 +2602,7 @@ function createWebUiApi(): NonNullable['ui']> { syncTrafficLights: () => {}, setMarkdownEditorFocused: () => {}, setTerminalInputFocused: () => {}, - setFloatingTerminalInputFocused: () => {}, + setFloatingFocus: () => {}, setShortcutRecorderFocused: () => {}, onRichMarkdownContextCommand: () => noopUnsubscribe, onFullscreenChanged: () => noopUnsubscribe, @@ -2755,6 +2758,17 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { } } +function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { + // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. + return { + onThreshold: () => noopUnsubscribe, + consumePending: () => Promise.resolve(null), + acknowledgePending: () => Promise.resolve(), + releasePending: () => Promise.resolve(), + dismiss: () => Promise.resolve() + } +} + function createDeveloperPermissionsApi(): NonNullable['developerPermissions']> { return { getStatus: () => Promise.resolve([]), @@ -2904,6 +2918,9 @@ function createAccountsApi(): never { // Why: launch accounts are recorded on the host that owns the PTY, which the // web client never is — report no stale panes rather than reject the sweep. listStalePanes: () => Promise.resolve([]), + // Why empty rather than absent: the same host owns both records, so a web + // client has no recorded lane to offer and every pane falls to derivation. + listRecordedPaneLanes: () => Promise.resolve({}), forgetStalePanes: () => Promise.resolve() } as never } @@ -2916,6 +2933,7 @@ function createUpdaterApi(): NonNullable['updater']> { download: () => Promise.resolve(), quitAndInstall: () => Promise.resolve(), dismissNudge: () => Promise.resolve(), + dismissAvailableUpdate: () => Promise.resolve(), onStatus: () => noopUnsubscribe, onClearDismissal: () => noopUnsubscribe } diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 5823f727ea5..3dafdd62d23 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,12 +1,19 @@ import { isAnteHeadlessOneShotCommand } from './ante-headless-command' -import { isClaudeHeadlessOneShotCommand } from './claude-headless-command' +import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command' import type { TuiAgent } from './types' +// Why: a table (not an if-chain) so adding an agent is one entry; Claude and Trae share +// the same `--print` one-shot contract, Ante's `--prompt` form needs its own matcher. +const HEADLESS_ONE_SHOT_MATCHERS: Partial< + Record boolean> +> = { + claude: isPrintModeHeadlessOneShotCommand, + trae: isPrintModeHeadlessOneShotCommand, + ante: isAnteHeadlessOneShotCommand +} + export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean { - if (agent === 'claude') { - return isClaudeHeadlessOneShotCommand(tokens) - } - return agent === 'ante' && isAnteHeadlessOneShotCommand(tokens) + return HEADLESS_ONE_SHOT_MATCHERS[agent]?.(tokens) ?? false } type AgentCommandRecognition = { agent: TuiAgent } | null diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7701ea6b1fe..ee3536bb932 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -45,6 +45,12 @@ import { upsertCodexSubagent, type CodexSubagentRoster } from './codex-subagent-roster' +import { + createCodexSubagentTranscriptState, + hasTrackedCodexTranscriptSubagents, + reconcileCodexSubagentTranscript, + type CodexSubagentTranscriptState +} from './codex-subagent-transcript' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' import { @@ -111,6 +117,8 @@ export type HookListenerState = { claudeLeadStateByPaneKey: Map /** Live thread-spawn children per Codex pane. */ codexSubagentRosterByPaneKey: Map + /** Incremental parent/child rollout cursors for Codex collaboration v2. */ + codexSubagentTranscriptByPaneKey: Map /** Root Codex state/model, kept separate from child hook traffic. */ codexLeadStateByPaneKey: Map } @@ -141,6 +149,7 @@ export function createHookListenerState(): HookListenerState { claudeSubagentRosterByPaneKey: new Map(), claudeLeadStateByPaneKey: new Map(), codexSubagentRosterByPaneKey: new Map(), + codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map() } } @@ -154,6 +163,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeLeadStateByPaneKey.delete(paneKey) state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) } @@ -197,6 +207,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) } @@ -238,6 +249,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.claudeSubagentRosterByPaneKey.clear() state.claudeLeadStateByPaneKey.clear() state.codexSubagentRosterByPaneKey.clear() + state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() } @@ -2266,6 +2278,7 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { switch (source) { case 'claude': // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. + // falls through case 'kimi': return eventName === 'UserPromptSubmit' case 'codex': @@ -2359,6 +2372,7 @@ function extractToolFields( switch (source) { case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + // falls through case 'kimi': return extractClaudeToolFields(eventName, hookPayload) case 'codex': @@ -3096,6 +3110,22 @@ function getOrCreateCodexSubagentRoster( return roster } +function getOrCreateCodexSubagentTranscriptState( + state: HookListenerState, + paneKey: string +): CodexSubagentTranscriptState { + let transcriptState = state.codexSubagentTranscriptByPaneKey.get(paneKey) + if (!transcriptState) { + transcriptState = createCodexSubagentTranscriptState() + state.codexSubagentTranscriptByPaneKey.set(paneKey, transcriptState) + } + return transcriptState +} + +export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: string): boolean { + return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) +} + export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, @@ -3176,7 +3206,7 @@ export function reconcileRemoteCodexState( } } else { const leadState = codexLeadStateForHookEvent(eventName) - if (eventName === 'SessionStart' || eventName === 'Stop') { + if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { roster.clear() } if (leadState) { @@ -3323,7 +3353,17 @@ function normalizeCodexEvent( if (eventName === 'SessionStart') { // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. state.codexSubagentRosterByPaneKey.delete(paneKey) - } else if (eventName === 'Stop') { + state.codexSubagentTranscriptByPaneKey.delete(paneKey) + } + const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) + if (transcriptPath) { + reconcileCodexSubagentTranscript( + getOrCreateCodexSubagentTranscriptState(state, paneKey), + getOrCreateCodexSubagentRoster(state, paneKey), + transcriptPath + ) + } + if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. state.codexSubagentRosterByPaneKey.delete(paneKey) } diff --git a/src/shared/agent-icons/trae.png b/src/shared/agent-icons/trae.png new file mode 100644 index 00000000000..2fc4e537c5e Binary files /dev/null and b/src/shared/agent-icons/trae.png differ diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index ddacd75659b..95a25a3c077 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -47,7 +47,8 @@ const TUI_AGENT_KIND_BY_AGENT = { copilot: 'copilot', grok: 'grok', devin: 'devin', - ante: 'ante' + ante: 'ante', + trae: 'trae' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index b6000f3325f..f84d81ba806 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -130,6 +130,42 @@ describe('agent process recognition', () => { }) }) + it('recognizes Trae by its traecli binary, not the ambiguous trae-cli name', () => { + expect(recognizeAgentProcess('traecli')).toEqual({ + agent: 'trae', + processName: 'traecli' + }) + expect(recognizeAgentProcess('/Users/dev/.local/bin/traecli')).toEqual({ + agent: 'trae', + processName: 'traecli' + }) + expect(isExpectedAgentProcess('/Users/dev/.local/bin/traecli', 'traecli')).toBe(true) + expect(isRecognizedAgentType('traecli')).toBe(true) + // Why: `trae-cli` and `trae-agent` both name the unrelated open-source bytedance/trae-agent. + expect(recognizeAgentProcess('trae-cli')).toBeNull() + expect(recognizeAgentProcess('trae-agent')).toBeNull() + }) + + it('does not recognize Trae headless one-shot commands as interactive agents', () => { + expect(recognizeAgentProcessFromCommandLine('traecli -p "summarize this diff"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('traecli --print "review this"')).toBeNull() + expect( + recognizeAgentProcessFromCommandLine('traecli --output-format json "review this"') + ).toBeNull() + expect( + recognizeAgentProcessFromCommandLine('traecli --output-format=stream-json review') + ).toBeNull() + expect(recognizeAgentProcessFromCommandLine('traecli --resume AUTO')).toEqual({ + agent: 'trae', + processName: 'traecli' + }) + // Why: past `--` nothing is a flag, so this is the interactive pane Orca itself launches. + expect(recognizeAgentProcessFromCommandLine('traecli -- "--print the release notes"')).toEqual({ + agent: 'trae', + processName: 'traecli' + }) + }) + it('recognizes Mistral Vibe by its installed executable and legacy alias', () => { expect(recognizeAgentProcess('/home/dev/.local/bin/vibe')).toEqual({ agent: 'mistral-vibe', diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 87bb9d38904..50581c5c919 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -186,6 +186,7 @@ export function extractAgentProviderSession( case 'gemini': case 'droid': // Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_). + // falls through case 'kimi': { const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 1968ee0890f..be22ce6e68d 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -37,6 +37,7 @@ export type WellKnownAgentType = | 'hermes' | 'devin' | 'ante' + | 'trae' | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index b638334f633..05d70d23868 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -22,6 +22,7 @@ const WELL_KNOWN_LABELS: Record = { hermes: 'Hermes', devin: 'Devin', ante: 'Ante', + trae: 'Trae', kimi: 'Kimi' } diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 4224f6835a7..39edd01038a 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -7,6 +7,10 @@ export type AiVaultPrepareSessionResumeArgs = Pick< export type AiVaultPrepareSessionResumeResult = { useRealCodexHome: boolean + // Why: cross-account hardlinking lists one rollout under several per-account + // homes, so the owning host repins resume to the selected account's home. + // Absent (older hosts included) means resume keeps the session's own home. + substituteCodexHome?: string } export type AiVaultSessionResumePreparation = ( @@ -33,3 +37,12 @@ export function isLegacySharedCodexHome(codexHome: string | null): boolean { const segments = codexHome.split(/[\\/]/).filter(Boolean) return segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' } + +/** Matches the managed `codex-accounts//home` layout, mirroring the AI Vault scan-root shape check. */ +export function isPerAccountManagedCodexHome(codexHome: string | null): boolean { + if (!codexHome) { + return false + } + const segments = codexHome.split(/[\\/]/).filter(Boolean) + return segments.at(-3) === 'codex-accounts' && segments.at(-1) === 'home' +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index f9195147286..530c8442183 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -323,6 +323,7 @@ function buildAgentResumeInvocation( // Why: Kimi Code resumes with `kimi --session ` (alias `-S`). Sessions // are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is // required — resuming from another directory is rejected by the CLI. + // falls through case 'kimi': return `${baseCommand} --session ${sessionArg}` case 'copilot': @@ -337,6 +338,7 @@ function buildAgentResumeInvocation( case 'droid': // Why: OMP resumes by absolute transcript path (see buildAiVaultResumeCommand), // but the `--resume ` invocation form is identical to the others here. + // falls through case 'omp': return `${baseCommand} --resume ${sessionArg}` case 'antigravity': diff --git a/src/shared/ante-headless-command.ts b/src/shared/ante-headless-command.ts index c777b2f808e..82fcec5d88f 100644 --- a/src/shared/ante-headless-command.ts +++ b/src/shared/ante-headless-command.ts @@ -1,9 +1,6 @@ -const ANTE_HEADLESS_PROMPT_FLAGS = new Set(['--prompt', '-p']) +import { optionName } from './print-mode-headless-command' -function optionName(token: string): string { - const eq = token.indexOf('=') - return eq === -1 ? token : token.slice(0, eq) -} +const ANTE_HEADLESS_PROMPT_FLAGS = new Set(['--prompt', '-p']) function isAnteHeadlessPromptFlag(token: string): boolean { const name = optionName(token) diff --git a/src/shared/claude-headless-command.ts b/src/shared/claude-headless-command.ts deleted file mode 100644 index b6647c37bff..00000000000 --- a/src/shared/claude-headless-command.ts +++ /dev/null @@ -1,32 +0,0 @@ -const CLAUDE_PRINT_MODE_FLAGS = new Set(['--print', '-p']) -const CLAUDE_HEADLESS_OUTPUT_FORMATS = new Set(['json', 'stream-json']) - -function optionName(token: string): string { - const eq = token.indexOf('=') - return eq === -1 ? token : token.slice(0, eq) -} - -function optionValue(tokens: readonly string[], index: number): string | null { - const token = tokens[index] - const eq = token.indexOf('=') - if (eq !== -1) { - return token.slice(eq + 1) - } - return tokens[index + 1] ?? null -} - -export function isClaudeHeadlessOneShotCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { - const name = optionName(tokens[index]) - if (CLAUDE_PRINT_MODE_FLAGS.has(name)) { - return true - } - if (name === '--output-format') { - const value = optionValue(tokens, index)?.toLowerCase() - if (value && CLAUDE_HEADLESS_OUTPUT_FORMATS.has(value)) { - return true - } - } - } - return false -} diff --git a/src/shared/codex-selection-lane.ts b/src/shared/codex-selection-lane.ts new file mode 100644 index 00000000000..c7b80f671f9 --- /dev/null +++ b/src/shared/codex-selection-lane.ts @@ -0,0 +1,36 @@ +export type CodexAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedCodexAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeCodexAccountSelectionTarget( + target?: CodexAccountSelectionTarget | null +): NormalizedCodexAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +/** Stable identifier for the selection lane a launch resolves its account from. */ +export function getCodexSelectionLaneKey(target?: CodexAccountSelectionTarget | null): string { + const normalized = normalizeCodexAccountSelectionTarget(target) + return normalized.runtime === 'host' ? 'host' : `wsl:${getWslSelectionKey(normalized.wslDistro)}` +} + +export function getWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} diff --git a/src/shared/codex-subagent-rollout-lifecycle.test.ts b/src/shared/codex-subagent-rollout-lifecycle.test.ts new file mode 100644 index 00000000000..9d476b85504 --- /dev/null +++ b/src/shared/codex-subagent-rollout-lifecycle.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener' +import { makePaneKey } from './stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +describe('Codex rollout subagent lifecycle', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('surfaces a collaboration child without lifecycle hooks and retires it on task_complete', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-rollout-lifecycle-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + jsonl([ + { + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: CHILD_ID, + agent_path: '/root/pr_review', + kind: 'started' + } + } + ]) + ) + writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) + const state = createHookListenerState() + const event = (hookEventName: string): ReturnType => + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: hookEventName, + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }, + 'production' + ) + + const spawned = event('PostToolUse') + expect(spawned?.payload).toMatchObject({ + state: 'working', + subagents: [ + { + id: CHILD_ID, + description: '/root/pr_review', + state: 'working', + startedAt: 1234 + } + ] + }) + + const leadStopped = event('Stop') + expect(leadStopped?.payload.state).toBe('working') + expect(leadStopped?.payload.subagents).toHaveLength(1) + + writeFileSync( + childPath, + jsonl([ + { type: 'event_msg', payload: { type: 'task_started' } }, + { type: 'event_msg', payload: { type: 'task_complete' } } + ]) + ) + const completed = event('Stop') + expect(completed?.payload.state).toBe('done') + expect(completed?.payload.subagents).toBeUndefined() + }) +}) diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts index ebb947dedf6..fde2c8cf849 100644 --- a/src/shared/codex-subagent-roster.test.ts +++ b/src/shared/codex-subagent-roster.test.ts @@ -20,6 +20,7 @@ describe('Codex subagent roster', () => { ' child-1 ', { agentType: `reviewer\n${'x'.repeat(AGENT_TYPE_MAX_LENGTH * 2)}`, + description: 'Review the sidebar lifecycle', model: `gpt-model-${'x'.repeat(AGENT_MODEL_MAX_LENGTH * 2)}`, state: 'working' }, @@ -30,6 +31,7 @@ describe('Codex subagent roster', () => { expect([...roster.keys()]).toEqual(['child-1']) expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH) expect(snapshot?.agentType).not.toContain('\n') + expect(snapshot?.description).toBe('Review the sidebar lifecycle') expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH) finishCodexSubagent(roster, ' child-1 ') diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts index 5c4d3e6d7a0..72cc216b76e 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/codex-subagent-roster.ts @@ -1,6 +1,7 @@ import { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_MAX_SUBAGENTS, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH, type AgentSubagentSnapshot } from './agent-status-types' @@ -12,6 +13,7 @@ export type CodexSubagentRoster = Map type TrackedCodexSubagent = { agentType?: string + description?: string model?: string state: 'working' | 'waiting' startedAt: number @@ -22,6 +24,7 @@ export function upsertCodexSubagent( id: string, fields: { agentType?: string + description?: string model?: string state: 'working' | 'waiting' }, @@ -32,10 +35,12 @@ export function upsertCodexSubagent( return } const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH) + const description = normalizeOptionalField(fields.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH) const existing = roster.get(normalizedId) if (existing) { existing.agentType = agentType ?? existing.agentType + existing.description = description ?? existing.description existing.model = model ?? existing.model existing.state = fields.state return @@ -45,6 +50,7 @@ export function upsertCodexSubagent( } roster.set(normalizedId, { agentType, + description, model, state: fields.state, startedAt: now @@ -66,7 +72,12 @@ export function seedCodexSubagentRoster( upsertCodexSubagent( roster, snapshot.id, - { agentType: snapshot.agentType, model: snapshot.model, state: snapshot.state }, + { + agentType: snapshot.agentType, + description: snapshot.description, + model: snapshot.model, + state: snapshot.state + }, snapshot.startedAt ) } @@ -81,6 +92,7 @@ export function codexRosterToSnapshots( const snapshots = Array.from(roster, ([id, tracked]) => ({ id, agentType: tracked.agentType, + description: tracked.description, model: tracked.model, state: tracked.state, startedAt: tracked.startedAt diff --git a/src/shared/codex-subagent-transcript.test.ts b/src/shared/codex-subagent-transcript.test.ts new file mode 100644 index 00000000000..d2f729912b5 --- /dev/null +++ b/src/shared/codex-subagent-transcript.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + createCodexSubagentTranscriptState, + hasTrackedCodexTranscriptSubagents, + reconcileCodexSubagentTranscript +} from './codex-subagent-transcript' +import { codexRosterToSnapshots, type CodexSubagentRoster } from './codex-subagent-roster' + +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +function activity(kind: string, occurredAtMs = 1234): unknown { + return { + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: occurredAtMs, + agent_thread_id: CHILD_ID, + agent_path: '/root/sidebar_repro', + kind + } + } +} + +/** `/YYYY/MM/DD` for a timestamp, matching how Codex buckets rollouts by local start date. */ +function dayDirectory(root: string, atMs: number): string { + const at = new Date(atMs) + const pad = (value: number): string => String(value).padStart(2, '0') + return join(root, String(at.getFullYear()), pad(at.getMonth() + 1), pad(at.getDate())) +} + +describe('Codex subagent transcript reconciliation', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('adds a child from the parent rollout and removes it after task completion', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, jsonl([activity('started')])) + writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + + reconcileCodexSubagentTranscript(state, roster, parentPath) + + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(true) + expect(codexRosterToSnapshots(roster)).toEqual([ + { + id: CHILD_ID, + description: '/root/sidebar_repro', + state: 'working', + startedAt: 1234, + agentType: undefined, + model: undefined + } + ]) + + writeFileSync( + childPath, + jsonl([ + { type: 'event_msg', payload: { type: 'task_started' } }, + { type: 'event_msg', payload: { type: 'task_complete' } } + ]) + ) + reconcileCodexSubagentTranscript(state, roster, parentPath) + + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + expect(codexRosterToSnapshots(roster)).toBeUndefined() + }) + + it('resolves a child rollout filed under a later session day than the parent', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(root) + const childStartedAt = Date.now() + const parentDir = dayDirectory(root, childStartedAt - 24 * 60 * 60 * 1000) + const childDir = dayDirectory(root, childStartedAt) + mkdirSync(parentDir, { recursive: true }) + mkdirSync(childDir, { recursive: true }) + const parentPath = join(parentDir, 'rollout-parent.jsonl') + const childPath = join(childDir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, jsonl([activity('started', childStartedAt)])) + writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + + reconcileCodexSubagentTranscript(state, roster, parentPath) + writeFileSync( + childPath, + jsonl([ + { type: 'event_msg', payload: { type: 'task_started' } }, + { type: 'event_msg', payload: { type: 'task_complete' } } + ]) + ) + reconcileCodexSubagentTranscript(state, roster, parentPath) + + // Why: only a cross-day lookup can observe the completion; the parent-directory scan never finds this file. + expect(roster.size).toBe(0) + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + }) + + it('retires a child whose rollout never becomes readable', () => { + vi.useFakeTimers() + try { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + writeFileSync(parentPath, jsonl([activity('started')])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + + reconcileCodexSubagentTranscript(state, roster, parentPath) + expect(roster.size).toBe(1) + + // Why: within the grace window a slow-to-appear rollout must not drop a live child. + vi.advanceTimersByTime(30_000) + reconcileCodexSubagentTranscript(state, roster, parentPath) + expect(roster.size).toBe(1) + + vi.advanceTimersByTime(31_000) + reconcileCodexSubagentTranscript(state, roster, parentPath) + expect(roster.size).toBe(0) + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('removes a child when Codex reports it interrupted', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + writeFileSync(parentPath, jsonl([activity('started')])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + reconcileCodexSubagentTranscript(state, roster, parentPath) + + writeFileSync(parentPath, jsonl([activity('started'), activity('interrupted')])) + reconcileCodexSubagentTranscript(state, roster, parentPath) + + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + expect(roster.size).toBe(0) + }) +}) diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts new file mode 100644 index 00000000000..3f9b30898c1 --- /dev/null +++ b/src/shared/codex-subagent-transcript.ts @@ -0,0 +1,299 @@ +import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from 'node:fs' +import { basename, dirname, extname, isAbsolute, join } from 'node:path' + +import { + finishCodexSubagent, + upsertCodexSubagent, + type CodexSubagentRoster +} from './codex-subagent-roster' + +const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024 +const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024 +const TRANSCRIPT_DIRECTORY_MAX_ENTRIES = 4096 +// Why: retire a child whose rollout stays unreadable this long, else a deleted/never-written file pins a phantom row forever. +const CHILD_UNREADABLE_GRACE_MS = 60_000 +const SAFE_THREAD_ID = /^[A-Za-z0-9-]{1,64}$/ + +type JsonlCursor = { + filePath?: string + offset: number + carry: string +} + +type TrackedTranscriptSubagent = JsonlCursor & { + description?: string + startedAt: number + unresolvedSince?: number +} + +export type CodexSubagentTranscriptState = { + parent: JsonlCursor + subagents: Map +} + +type JsonRecord = Record + +function record(value: unknown): JsonRecord | undefined { + return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined +} + +/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. */ +function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { + if (!cursor.filePath) { + return undefined + } + let stats: Stats + try { + stats = statSync(cursor.filePath) + } catch { + return undefined + } + if (!stats.isFile()) { + return undefined + } + if (stats.size < cursor.offset) { + cursor.offset = 0 + cursor.carry = '' + } + if (stats.size === cursor.offset) { + return [] + } + const bytesToRead = Math.min(stats.size - cursor.offset, TRANSCRIPT_READ_MAX_BYTES) + const start = stats.size - cursor.offset > bytesToRead ? stats.size - bytesToRead : cursor.offset + const buffer = Buffer.allocUnsafe(bytesToRead) + let bytesRead = 0 + let fd: number | undefined + try { + fd = openSync(cursor.filePath, 'r') + bytesRead = readSync(fd, buffer, 0, bytesToRead, start) + } catch { + return undefined + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } + const skippedPrefix = start !== cursor.offset + const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}` + const lines = content.split('\n') + cursor.offset = start + bytesRead + cursor.carry = lines.pop() ?? '' + if (skippedPrefix) { + lines.shift() + } + const records: JsonRecord[] = [] + for (const line of lines) { + if (Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES) { + continue + } + try { + const parsed = record(JSON.parse(line) as unknown) + if (parsed) { + records.push(parsed) + } + } catch { + // A malformed rollout line must not block later lifecycle events. + } + } + return records +} + +function readTranscriptDirectory(directory: string): string[] { + let entries: string[] + try { + entries = readdirSync(directory) + } catch { + return [] + } + if (entries.length > TRANSCRIPT_DIRECTORY_MAX_ENTRIES) { + entries = entries.slice(-TRANSCRIPT_DIRECTORY_MAX_ENTRIES) + } + return entries +} + +// Why: Codex files each rollout under its OWN local start date, so a session running past midnight spawns children into a sibling day directory. +function childDayDirectory(parentPath: string, startedAt: number): string | undefined { + const dayDir = dirname(parentPath) + const monthDir = dirname(dayDir) + const yearDir = dirname(monthDir) + if ( + !/^\d{2}$/.test(basename(dayDir)) || + !/^\d{2}$/.test(basename(monthDir)) || + !/^\d{4}$/.test(basename(yearDir)) || + !Number.isFinite(startedAt) + ) { + return undefined + } + const startedOn = new Date(startedAt) + if (Number.isNaN(startedOn.getTime())) { + return undefined + } + const pad = (value: number): string => String(value).padStart(2, '0') + return join( + dirname(yearDir), + String(startedOn.getFullYear()).padStart(4, '0'), + pad(startedOn.getMonth() + 1), + pad(startedOn.getDate()) + ) +} + +function resolveChildTranscript( + parentPath: string, + threadId: string, + startedAt: number, + entriesByDirectory: Map +): string | undefined { + if (!SAFE_THREAD_ID.test(threadId)) { + return undefined + } + const suffix = `-${threadId}.jsonl` + const parentDir = dirname(parentPath) + const childDir = childDayDirectory(parentPath, startedAt) + const directories = childDir && childDir !== parentDir ? [parentDir, childDir] : [parentDir] + for (const directory of directories) { + let entries = entriesByDirectory.get(directory) + if (!entries) { + entries = readTranscriptDirectory(directory) + entriesByDirectory.set(directory, entries) + } + const fileName = entries.find((entry) => entry.endsWith(suffix)) + if (fileName) { + return join(directory, fileName) + } + } + return undefined +} + +function readActivity(recordValue: JsonRecord): + | { + id: string + description?: string + kind: 'started' | 'interacted' | 'interrupted' + startedAt: number + } + | undefined { + if (recordValue.type !== 'event_msg') { + return undefined + } + const payload = record(recordValue.payload) + if (payload?.type !== 'sub_agent_activity') { + return undefined + } + const id = typeof payload.agent_thread_id === 'string' ? payload.agent_thread_id.trim() : '' + const rawKind = typeof payload.kind === 'string' ? payload.kind.toLowerCase() : '' + if ( + !SAFE_THREAD_ID.test(id) || + (rawKind !== 'started' && rawKind !== 'interacted' && rawKind !== 'interrupted') + ) { + return undefined + } + return { + id, + description: + typeof payload.agent_path === 'string' ? payload.agent_path.trim() || undefined : undefined, + kind: rawKind, + startedAt: + typeof payload.occurred_at_ms === 'number' && Number.isFinite(payload.occurred_at_ms) + ? payload.occurred_at_ms + : Date.now() + } +} + +function childIsComplete(records: JsonRecord[]): boolean { + let complete = false + for (const recordValue of records) { + if (recordValue.type !== 'event_msg') { + continue + } + const payload = record(recordValue.payload) + if (payload?.type === 'task_started') { + complete = false + } else if (payload?.type === 'task_complete') { + complete = true + } + } + return complete +} + +export function createCodexSubagentTranscriptState(): CodexSubagentTranscriptState { + return { + parent: { offset: 0, carry: '' }, + subagents: new Map() + } +} + +export function hasTrackedCodexTranscriptSubagents( + state: CodexSubagentTranscriptState | undefined +): boolean { + return Boolean(state && state.subagents.size > 0) +} + +export function reconcileCodexSubagentTranscript( + state: CodexSubagentTranscriptState, + roster: CodexSubagentRoster, + transcriptPath: string | undefined +): void { + const normalizedPath = transcriptPath?.trim() + if (!normalizedPath || !isAbsolute(normalizedPath) || extname(normalizedPath) !== '.jsonl') { + return + } + if (state.parent.filePath !== normalizedPath) { + for (const id of state.subagents.keys()) { + finishCodexSubagent(roster, id) + } + state.parent = { filePath: normalizedPath, offset: 0, carry: '' } + state.subagents.clear() + } + for (const recordValue of readJsonlCursor(state.parent) ?? []) { + const activity = readActivity(recordValue) + if (!activity) { + continue + } + if (activity.kind === 'interrupted') { + finishCodexSubagent(roster, activity.id) + state.subagents.delete(activity.id) + continue + } + const tracked = state.subagents.get(activity.id) ?? { + offset: 0, + carry: '', + startedAt: activity.startedAt + } + tracked.description = activity.description ?? tracked.description + state.subagents.set(activity.id, tracked) + upsertCodexSubagent( + roster, + activity.id, + { description: tracked.description, state: 'working' }, + tracked.startedAt + ) + } + const entriesByDirectory = new Map() + const now = Date.now() + for (const [id, tracked] of state.subagents) { + if (!tracked.filePath) { + tracked.filePath = resolveChildTranscript( + normalizedPath, + id, + tracked.startedAt, + entriesByDirectory + ) + } + const records = readJsonlCursor(tracked) + if (!records) { + // Why: a rollout that never appears (or is deleted) has no completion event, so time-box it instead of leaking a working row. + tracked.filePath = undefined + tracked.unresolvedSince ??= now + if (now - tracked.unresolvedSince <= CHILD_UNREADABLE_GRACE_MS) { + continue + } + } else { + tracked.unresolvedSince = undefined + if (!childIsComplete(records)) { + continue + } + } + finishCodexSubagent(roster, id) + state.subagents.delete(id) + } +} diff --git a/src/shared/combined-diff-file-tree-width.test.ts b/src/shared/combined-diff-file-tree-width.test.ts new file mode 100644 index 00000000000..069c46046f1 --- /dev/null +++ b/src/shared/combined-diff-file-tree-width.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + clampCombinedDiffFileTreeWidth, + COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH, + COMBINED_DIFF_FILE_TREE_MAX_WIDTH, + COMBINED_DIFF_FILE_TREE_MIN_WIDTH, + computeCombinedDiffFileTreeWidthBounds +} from './combined-diff-file-tree-width' + +describe('combined diff file tree width', () => { + it('keeps both panes usable in a narrow container', () => { + expect(clampCombinedDiffFileTreeWidth(600, 500)).toBe(300) + }) + + it('applies the tree width bounds', () => { + expect(clampCombinedDiffFileTreeWidth(100, 1_000)).toBe(COMBINED_DIFF_FILE_TREE_MIN_WIDTH) + expect(clampCombinedDiffFileTreeWidth(1_000, 1_000)).toBe(COMBINED_DIFF_FILE_TREE_MAX_WIDTH) + }) + + it('treats an unmeasurable container as unconstrained so a hidden pane cannot shrink the width', () => { + expect(clampCombinedDiffFileTreeWidth(500, 0)).toBe(500) + expect(clampCombinedDiffFileTreeWidth(500, Number.NaN)).toBe(500) + expect(computeCombinedDiffFileTreeWidthBounds(0)).toEqual({ + maxWidth: COMBINED_DIFF_FILE_TREE_MAX_WIDTH, + minWidth: COMBINED_DIFF_FILE_TREE_MIN_WIDTH + }) + }) + + it('falls back for non-finite stored values', () => { + expect(clampCombinedDiffFileTreeWidth(undefined)).toBe(COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH) + expect(clampCombinedDiffFileTreeWidth(Number.NaN)).toBe(COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH) + expect(clampCombinedDiffFileTreeWidth('320')).toBe(COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH) + expect(clampCombinedDiffFileTreeWidth(undefined, undefined, 400)).toBe(400) + }) + + it('splits a container too narrow for both minimums instead of overflowing the diff pane', () => { + expect(computeCombinedDiffFileTreeWidthBounds(300)).toEqual({ maxWidth: 150, minWidth: 150 }) + expect(clampCombinedDiffFileTreeWidth(256, 300)).toBe(150) + expect(clampCombinedDiffFileTreeWidth(100, 300)).toBe(150) + // Why: at exactly 400 both minimums fit, so the tree keeps its own minimum. + expect(computeCombinedDiffFileTreeWidthBounds(400)).toEqual({ + maxWidth: COMBINED_DIFF_FILE_TREE_MIN_WIDTH, + minWidth: COMBINED_DIFF_FILE_TREE_MIN_WIDTH + }) + expect(clampCombinedDiffFileTreeWidth(256, 399)).toBe(199) + }) + + it('clamps a negative drag width to the minimum', () => { + expect(clampCombinedDiffFileTreeWidth(-120, 1_000)).toBe(COMBINED_DIFF_FILE_TREE_MIN_WIDTH) + }) +}) diff --git a/src/shared/combined-diff-file-tree-width.ts b/src/shared/combined-diff-file-tree-width.ts new file mode 100644 index 00000000000..6e999a33fb7 --- /dev/null +++ b/src/shared/combined-diff-file-tree-width.ts @@ -0,0 +1,51 @@ +export const COMBINED_DIFF_FILE_TREE_MIN_WIDTH = 200 +export const COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH = 256 +export const COMBINED_DIFF_FILE_TREE_MIN_DIFF_WIDTH = 200 +export const COMBINED_DIFF_FILE_TREE_MAX_WIDTH = 640 +export const COMBINED_DIFF_FILE_TREE_RESIZE_STEP = 16 + +export function computeCombinedDiffFileTreeWidthBounds(containerWidth: number): { + maxWidth: number + minWidth: number +} { + // Why: a hidden pane measures 0; treat that as "unknown" so it can't shrink the stored width. + if (!Number.isFinite(containerWidth) || containerWidth <= 0) { + return { + maxWidth: COMBINED_DIFF_FILE_TREE_MAX_WIDTH, + minWidth: COMBINED_DIFF_FILE_TREE_MIN_WIDTH + } + } + + const fittedMax = Math.min( + COMBINED_DIFF_FILE_TREE_MAX_WIDTH, + containerWidth - COMBINED_DIFF_FILE_TREE_MIN_DIFF_WIDTH + ) + if (fittedMax >= COMBINED_DIFF_FILE_TREE_MIN_WIDTH) { + return { maxWidth: fittedMax, minWidth: COMBINED_DIFF_FILE_TREE_MIN_WIDTH } + } + + // Why: below both minimums combined nothing fits, so split the container evenly rather than + // hold the tree at its minimum and push the diff pane out of the container. + const shared = Math.max(0, Math.floor(containerWidth / 2)) + return { maxWidth: shared, minWidth: shared } +} + +export function clampCombinedDiffFileTreeWidth( + width: unknown, + containerWidth?: number, + fallback = COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH +): number { + if (typeof width !== 'number' || !Number.isFinite(width)) { + return fallback + } + + const { maxWidth, minWidth } = + containerWidth !== undefined + ? computeCombinedDiffFileTreeWidthBounds(containerWidth) + : { + maxWidth: COMBINED_DIFF_FILE_TREE_MAX_WIDTH, + minWidth: COMBINED_DIFF_FILE_TREE_MIN_WIDTH + } + + return Math.min(maxWidth, Math.max(minWidth, width)) +} diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index e19504fc5ed..a86c89a174b 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { + createCommandCodeOutputStatusDetector, + stripTerminalControl +} from './command-code-output-status' afterEach(() => { vi.restoreAllMocks() @@ -317,6 +320,44 @@ function maxStringContextLength(contexts: unknown[]): number { } describe('terminal control stripping', () => { + const esc = String.fromCharCode(0x1b) + const bel = String.fromCharCode(0x07) + const ansiEscape = new RegExp( + `${esc}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${bel}]*(?:${bel}|${esc}\\\\))`, + 'g' + ) + const incompleteAnsiEscape = new RegExp( + `${esc}(?:\\[[0-?]*[ -/]*|\\][^${bel}${esc}]*|\\S?)?$`, + 'g' + ) + + function legacyStripTerminalControl(data: string): string { + const withoutAnsi = data.replace(ansiEscape, '').replace(incompleteAnsiEscape, '') + let output = '' + for (let index = 0; index < withoutAnsi.length; index += 1) { + const code = withoutAnsi.charCodeAt(index) + if ((code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)) { + continue + } + output += withoutAnsi[index] + } + return output + } + + function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return (state >>> 0) / 0x1_0000_0000 + } + } + + function expectLegacyEquivalent(data: string): void { + expect(stripTerminalControl(data)).toBe(legacyStripTerminalControl(data)) + } + function promptFrom(raw: string): string | null { let captured: string | null = null const detector = createCommandCodeOutputStatusDetector({ @@ -347,4 +388,78 @@ describe('terminal control stripping', () => { '日本語 \u{1f389} prompt' ) }) + + it('matches the legacy filter across ANSI, C0/C1, line breaks, and Unicode', () => { + for (const data of [ + '', + 'plain text', + '\x1b[35mstyled\x1b[0m', + '\x1b[unterminated', + '\x00leading', + 'trailing\x9f', + '\x01\x02adjacent\x7f\x80', + 'keep\r\nline breaks', + '日本語 \u{1f389} \ud83d \ude00', + '\x1b]0;title\x07prompt', + '\x1b]0;title\x1b\\prompt' + ]) { + expectLegacyEquivalent(data) + } + }) + + it('matches legacy output at density thresholds and block resets', () => { + const control = '\x01' + const fixtures = [ + `${control.repeat(31)}${'a'.repeat(33)}`, + `${control.repeat(32)}${'a'.repeat(32)}`, + `${'a'.repeat(32)}${control.repeat(31)}a`, + `${control.repeat(31)}${'a'.repeat(33)}${control.repeat(31)}z`, + `${control.repeat(31)}${'a'.repeat(33)}${control.repeat(32)}z`, + `${'a'.repeat(64 * 3)}${control.repeat(32)}tail`, + `${'a\x01'.repeat(2048)}tail` + ] + for (const data of fixtures) { + expectLegacyEquivalent(data) + } + }) + + it('exhaustively matches short strings over control and Unicode code units', () => { + const alphabet = ['a', '\r', '\n', '\x01', '\x7f', '\x80', '\u20ac', '\ud83d'] + for (let encoded = 0; encoded < alphabet.length ** 4; encoded += 1) { + let cursor = encoded + let data = '' + for (let position = 0; position < 4; position += 1) { + data += alphabet[cursor % alphabet.length] + cursor = Math.floor(cursor / alphabet.length) + } + expectLegacyEquivalent(data) + } + }) + + it('matches seeded random terminal text', () => { + const random = makeRandom(0xc0de_0727) + const alphabet = [ + 'a', + 'Z', + '\r', + '\n', + '\x00', + '\x1f', + '\x7f', + '\x9f', + '\u00e9', + '\u20ac', + '\u{1f389}', + '\x1b[35m', + '\x1b[0m' + ] + for (let trial = 0; trial < 2_000; trial += 1) { + let data = '' + const parts = Math.floor(random() * 256) + for (let index = 0; index < parts; index += 1) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + expectLegacyEquivalent(data) + } + }) }) diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 70bb9ff039e..889c9a787e0 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -9,21 +9,14 @@ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate } from './command-code-prompt-text' +import { stripTerminalControl } from './terminal-control-stripping' + +export { stripTerminalControl } from './terminal-control-stripping' type CommandCodeOutputStatusDetector = { observe: (data: string) => boolean } -const ESC = String.fromCharCode(0x1b) -const BEL = String.fromCharCode(0x07) -const ANSI_ESCAPE_RE = new RegExp( - `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, - 'g' -) -const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( - `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, - 'g' -) const RECENT_TEXT_LIMIT = 300 const STATUS_SCAN_TEXT_LIMIT = 4096 const COMMAND_CODE_STATUS_GLYPH_RE_SOURCE = '[·○◇☆✧⌘✻⎿]' @@ -121,41 +114,6 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp( const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./ const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/ -function stripTerminalControl(data: string): string { - if (!terminalControlMayAffectText(data)) { - return data - } - const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') - // Four calls per PTY chunk favor copying sparse intact runs over per-character concatenation. - let output = '' - let runStart = 0 - for (let index = 0; index < withoutAnsi.length; index += 1) { - const code = withoutAnsi.charCodeAt(index) - if ((code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)) { - if (index > runStart) { - output += withoutAnsi.slice(runStart, index) - } - runStart = index + 1 - } - } - return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) -} - -function terminalControlMayAffectText(data: string): boolean { - for (let index = 0; index < data.length; index += 1) { - const code = data.charCodeAt(index) - if ( - code === 0x0d || - code === 0x1b || - (code <= 0x1f && code !== 0x0a) || - (code >= 0x7f && code <= 0x9f) - ) { - return true - } - } - return false -} - function cleanPromptCandidate(value: string): string { return cleanCommandCodePromptCandidate(stripTerminalControl(value)) } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7c11e607d96..283deb15ad7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -451,6 +451,7 @@ export function getDefaultUIState(): PersistedUIState { rightSidebarExplorerView: 'files', rightSidebarWidth: 350, markdownTocPanelWidth: 240, + combinedDiffFileTreeWidth: 256, groupBy: 'repo', sortBy: 'recent', projectOrderBy: 'manual', diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts new file mode 100644 index 00000000000..4c5289db8fa --- /dev/null +++ b/src/shared/detected-worktree-provider-contract.ts @@ -0,0 +1,56 @@ +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { DirectSshAuthority } from './ssh-types' +import type { DetectedWorktreeListResult } from './types' + +export const PROVIDER_REQUEST_ID_MAX_UTF8_BYTES = 128 +export type ProviderRequestId = string & { readonly __providerRequestId: unique symbol } +export type SshExecutionHostId = Extract + +export type LocalDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: typeof LOCAL_EXECUTION_HOST_ID +} + +export type DirectSshDetectedWorktreeRequest = { + providerRequestId: ProviderRequestId + repoId: string + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority +} + +export type ListDetectedWorktreesArgs = + | LocalDetectedWorktreeRequest + | DirectSshDetectedWorktreeRequest + +export type AuthoritativeDetectedWorktreeHost = + | { + kind: 'local' + executionHostId: typeof LOCAL_EXECUTION_HOST_ID + } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + +export type HostQualifiedDetectedWorktreeResult = + | { + status: 'complete' | 'non-authoritative' + providerRequestId: ProviderRequestId + repoId: string + authority: AuthoritativeDetectedWorktreeHost + result: DetectedWorktreeListResult + } + | { + providerRequestId: ProviderRequestId + executionHostId: ExecutionHostId + status: + | 'canceled' + | 'timed-out' + | 'stale' + | 'ambiguous-owner' + | 'authority-unknown' + | 'rejected' + } + +export type LegacyDetectedWorktreeRequest = { repoId: string } diff --git a/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts b/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts new file mode 100644 index 00000000000..10d71e95a24 --- /dev/null +++ b/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas, type EventProps } from './telemetry-events' + +const validEvent = { + mode: 'reconnect', + reason: 'wake_refresh', + outcome: 'degraded', + terminal_retried_count: 3, + terminal_stale_binding_cleared_count: 1, + terminal_correction_succeeded_count: 2, + catalog_complete_count: 1, + catalog_degraded_count: 0, + catalog_stale_count: 0, + repo_complete_count: 2, + repo_non_authoritative_count: 1, + repo_retrying_count: 1, + repo_timed_out_count: 1, + repo_cancel_budget_exhausted_count: 0, + repo_canceled_count: 0, + repo_stale_count: 0, + repo_rejected_count: 1, + lineage_complete_count: 0, + lineage_degraded_count: 1, + lineage_canceled_count: 0, + lineage_stale_count: 0, + lineage_not_started_count: 0, + git_worktree_count: 4, + folder_workspace_count: 2, + ambiguous_owner_count: 1, + contradictory_owner_count: 1, + total_duration_ms: 750, + terminal_finalization_duration_ms: 5, + catalog_duration_ms: 25, + queue_wait_sample_count: 3, + queue_wait_duration_ms_p50: 10, + queue_wait_duration_ms_p95: 30, + queue_wait_duration_ms_p99: 30, + queue_wait_duration_ms_max: 30, + provider_execution_sample_count: 3, + provider_execution_duration_ms_p50: 100, + provider_execution_duration_ms_p95: 300, + provider_execution_duration_ms_p99: 300, + provider_execution_duration_ms_max: 300, + timeout_retry_count: 1, + locally_settled_waiter_count: 3, + cancel_debt_count: 1, + replacement_admission_delayed_count: 0, + overlapping_join_count: 1, + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 3, + estimated_late_work_allowance_count: 1, + authority_rotation_count: 1, + damped_preparation_count: 0 +} satisfies EventProps<'direct_ssh_reconnect_operation'> + +describe('direct SSH reconnect telemetry schema', () => { + it('accepts the aggregate count and duration distribution', () => { + expect(eventSchemas.direct_ssh_reconnect_operation.safeParse(validEvent).success).toBe(true) + }) + + it.each([ + 'target_id', + 'repo_id', + 'host', + 'path', + 'label', + 'username', + 'request_id', + 'lease_id', + 'terminal_id', + 'error', + 'raw_error' + ])('rejects identifier or raw failure field %s', (field) => { + expect( + eventSchemas.direct_ssh_reconnect_operation.safeParse({ + ...validEvent, + [field]: 'sensitive-value' + }).success + ).toBe(false) + }) + + it('keeps timeout, rejection, cancellation, and stale counts independent', () => { + const parsed = eventSchemas.direct_ssh_reconnect_operation.parse({ + ...validEvent, + repo_timed_out_count: 2, + repo_rejected_count: 3, + repo_canceled_count: 4, + repo_stale_count: 5 + }) + + expect([ + parsed.repo_timed_out_count, + parsed.repo_rejected_count, + parsed.repo_canceled_count, + parsed.repo_stale_count + ]).toEqual([2, 3, 4, 5]) + }) + + it('rejects concurrency and late-work values beyond scheduler policy', () => { + expect( + eventSchemas.direct_ssh_reconnect_operation.safeParse({ + ...validEvent, + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 6 + }).success + ).toBe(false) + expect( + eventSchemas.direct_ssh_reconnect_operation.safeParse({ + ...validEvent, + estimated_late_work_allowance_count: 3 + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts new file mode 100644 index 00000000000..fba621b7a9c --- /dev/null +++ b/src/shared/emoji-shortcode-catalog.ts @@ -0,0 +1,49 @@ +import emojiShortcodes from 'emojibase-data/en/shortcodes/github.json' + +export type StandardEmojiShortcodeEntry = { + emoji: string + shortcode: string +} + +export const STANDARD_EMOJI_SHORTCODE_ENTRIES: readonly StandardEmojiShortcodeEntry[] = + Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + const shortcodes = typeof value === 'string' ? [value] : value + const emoji = hexcodeToEmoji(hexcode) + return shortcodes.map((shortcode) => ({ emoji, shortcode })) + }) + +const PRIMARY_SHORTCODE_BY_EMOJI = new Map( + Object.entries(emojiShortcodes).map(([hexcode, value]) => { + const shortcodes = typeof value === 'string' ? [value] : value + const shortcode = shortcodes.find((candidate) => /^[a-z]/i.test(candidate)) ?? shortcodes[0] + return [normalizeEmojiLookup(hexcodeToEmoji(hexcode)), shortcode] + }) +) + +const EMOJI_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' }) + +export function replaceKnownEmojiWithShortcodes(input: string): string { + return Array.from(EMOJI_SEGMENTER.segment(input), ({ segment }) => { + const shortcode = PRIMARY_SHORTCODE_BY_EMOJI.get(normalizeEmojiLookup(segment)) + return shortcode ? ` ${shortcode.replaceAll('_', '-')} ` : segment + }).join('') +} + +function normalizeEmojiLookup(emoji: string): string { + return Array.from(emoji) + .filter((character) => { + const codepoint = character.codePointAt(0) + return ( + character !== '\ufe0f' && + (codepoint === undefined || codepoint < 0x1f3fb || codepoint > 0x1f3ff) + ) + }) + .join('') +} + +function hexcodeToEmoji(hexcode: string): string { + return hexcode + .split('-') + .map((codepoint) => String.fromCodePoint(Number.parseInt(codepoint, 16))) + .join('') +} diff --git a/src/shared/ephemeral-vm-recipes.test.ts b/src/shared/ephemeral-vm-recipes.test.ts index b074c438327..d83ad44bb68 100644 --- a/src/shared/ephemeral-vm-recipes.test.ts +++ b/src/shared/ephemeral-vm-recipes.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing' import { EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS, + parseEphemeralVmRecipeResult +} from './ephemeral-vm-recipes' +import { getEphemeralVmRecipeResultWarnings, - parseEphemeralVmRecipeResult, redactEphemeralVmRecipeDiagnosticText, redactEphemeralVmRecipeResultForDiagnostics -} from './ephemeral-vm-recipes' +} from './ephemeral-vm-recipe-diagnostics' function makePairingCode(endpoint = 'wss://sandbox.example.com'): string { return encodePairingOffer({ diff --git a/src/shared/ephemeral-vm-recipes.ts b/src/shared/ephemeral-vm-recipes.ts index 0f68bc92f84..071f1e89c46 100644 --- a/src/shared/ephemeral-vm-recipes.ts +++ b/src/shared/ephemeral-vm-recipes.ts @@ -6,16 +6,6 @@ import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from './ssh-types' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -// Why: ephemeral-vm-recipe-doctor imports Node's fs/path, so it must NOT be -// re-exported through this barrel — the renderer/web-client imports this module -// and would otherwise pull Node built-ins into the browser bundle (build fails). -// Node callers import doctorEphemeralVmRecipe directly from the doctor module. -export { - getEphemeralVmRecipeResultWarnings, - redactEphemeralVmRecipeDiagnosticText, - redactEphemeralVmRecipeResultForDiagnostics -} from './ephemeral-vm-recipe-diagnostics' -export type { EphemeralVmRecipeResultWarning } from './ephemeral-vm-recipe-diagnostics' const JsonValueSchema: z.ZodType = z.lazy(() => z.union([ diff --git a/src/shared/external-worktree-inbox.ts b/src/shared/external-worktree-inbox.ts index 6b6fdaa74aa..56aea01ff8b 100644 --- a/src/shared/external-worktree-inbox.ts +++ b/src/shared/external-worktree-inbox.ts @@ -3,7 +3,7 @@ import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types import { effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility -} from './worktree-ownership' +} from './external-worktree-visibility' export function normalizeExternalWorktreeInboxPath(path: string): string { return normalizeRuntimePathForComparison(path) diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index d1a5d4be55c..be5d24a524c 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -1,5 +1,7 @@ import { normalizeRuntimePathSeparators } from './cross-platform-path' +import type { ExternalWorktreeVisibility, Repo } from './types' +export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23) export const UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH = 'Unknown location' function trimRuntimePathTrailingSlash(value: string): string { @@ -42,3 +44,26 @@ export function getExternalWorktreeParentPath(worktreePath: string | undefined): } return normalized.slice(0, lastSeparatorIndex) } + +export function isLegacyRepoForExternalWorktreeVisibility(repo: Repo): boolean { + if (typeof repo.externalWorktreeVisibilityLegacy === 'boolean') { + return repo.externalWorktreeVisibilityLegacy + } + if (repo.externalWorktreeVisibility === undefined) { + return true + } + if (!Number.isFinite(repo.addedAt)) { + return true + } + return repo.addedAt < EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT +} + +export function effectiveExternalWorktreeVisibility( + repo: Pick, + isLegacyRepoForVisibility: boolean +): ExternalWorktreeVisibility { + if (repo.externalWorktreeVisibility) { + return repo.externalWorktreeVisibility + } + return isLegacyRepoForVisibility ? 'show' : 'hide' +} diff --git a/src/shared/host-lineage-contract.ts b/src/shared/host-lineage-contract.ts new file mode 100644 index 00000000000..6541b84abac --- /dev/null +++ b/src/shared/host-lineage-contract.ts @@ -0,0 +1,29 @@ +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { SshExecutionHostId } from './detected-worktree-provider-contract' +import type { DirectSshAuthority } from './ssh-types' +import type { WorktreeLineage, WorkspaceLineage } from './types' + +export type ListDesktopLineageForHostArgs = + | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority + } + +export type HostLineageSnapshot = + | { + authoritative: true + authority: + | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + worktreeLineageById: Record + workspaceLineageByChildKey: Record + } + | { + authoritative: false + executionHostId: ExecutionHostId + reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' + } diff --git a/src/shared/host-repo-catalog-contract.ts b/src/shared/host-repo-catalog-contract.ts new file mode 100644 index 00000000000..6a44bf8bd85 --- /dev/null +++ b/src/shared/host-repo-catalog-contract.ts @@ -0,0 +1,28 @@ +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { SshExecutionHostId } from './detected-worktree-provider-contract' +import type { DirectSshAuthority } from './ssh-types' +import type { Repo } from './types' + +export type ListReposForExecutionHostArgs = + | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + executionHostId: SshExecutionHostId + expectedAuthority: DirectSshAuthority + } + +export type HostRepoCatalogSnapshot = + | { + authoritative: true + authority: + | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } + | ({ + kind: 'direct-ssh' + executionHostId: SshExecutionHostId + } & DirectSshAuthority) + repos: readonly Repo[] + } + | { + authoritative: false + executionHostId: ExecutionHostId + reason: 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' + } diff --git a/src/shared/image-data-uri.test.ts b/src/shared/image-data-uri.test.ts index 46a9126591d..7e62cb0b3df 100644 --- a/src/shared/image-data-uri.test.ts +++ b/src/shared/image-data-uri.test.ts @@ -39,12 +39,17 @@ describe('buildImageDataUri', () => { expect(buildImageDataUri('application/octet-stream', 'AAAA')).toBeNull() }) - it('rejects malformed and oversized known rasters before native decode', () => { - expect(buildImageDataUri('image/png', 'bmV3')).toBeNull() + it('rejects oversized known rasters before native decode', () => { expect(buildImageDataUri('image/png', pngBase64(32_769, 1))).toBeNull() expect(buildImageDataUri('image/png', pngBase64(8192, 8192))).toBeNull() }) + it('still renders bytes whose header we cannot measure', () => { + // Failing to read a header means we could not measure the image, not that it is oversized. + // The decoder handles encodings this parser does not, so suppressing here blanks valid images. + expect(buildImageDataUri('image/png', 'bmV3')).toBe('data:image/png;base64,bmV3') + }) + it('preserves SVG behavior because vectors do not have encoded raster dimensions', () => { expect(buildImageDataUri('image/svg+xml', 'PHN2Zy8+')).toBe( 'data:image/svg+xml;base64,PHN2Zy8+' diff --git a/src/shared/image-data-uri.ts b/src/shared/image-data-uri.ts index e8d5f66c623..2d0e351c847 100644 --- a/src/shared/image-data-uri.ts +++ b/src/shared/image-data-uri.ts @@ -1,4 +1,4 @@ -import { readRasterImagePreviewDimensionsFromBase64 } from './raster-image-base64-preview' +import { exceedsRasterImagePreviewLimits } from './raster-image-base64-preview' import { isKnownRasterImageMimeType } from './raster-image-preview-limits' // Builds an inline `data:` URI for base64 image bytes, shared by the desktop @@ -19,7 +19,9 @@ export function buildImageDataUri( if (!cleaned) { return null } - if (readRasterImagePreviewDimensionsFromBase64(cleaned, mimeType) === null) { + // Only suppress when the header says the image is too large to render safely. An unreadable + // header is not evidence of an oversized image, and the decoder handles formats we cannot parse. + if (exceedsRasterImagePreviewLimits(cleaned, mimeType)) { return null } return `data:${mimeType};base64,${cleaned}` diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index f4b1a07abfe..f10216ddeee 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 29, + "daemonProtocolVersion": 30, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29 + 27, 28, 29, 30 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index ad80dd85808..ecc9fcef184 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 29, + daemonProtocolVersion: 30, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29 + 27, 28, 29, 30 ] } as const diff --git a/src/shared/orca-yaml-alias-bounds.test.ts b/src/shared/orca-yaml-alias-bounds.test.ts index 611c69a1fe0..d0b2a8c632e 100644 --- a/src/shared/orca-yaml-alias-bounds.test.ts +++ b/src/shared/orca-yaml-alias-bounds.test.ts @@ -12,16 +12,36 @@ scripts: ).toMatchObject({ scripts: { setup: 'pnpm install' } }) }) - it('rejects alias expansion beyond the explicit conversion cap', () => { - const aliases = Array.from({ length: 21 }, () => '*items').join(', ') + it('keeps reusing one anchor across a realistic tab list', () => { + // Flat reuse costs anchor size x uses, which the file-size limit already bounds. Rejecting it + // broke ordinary configs that merge shared defaults into every tab. + const tabs = Array.from( + { length: 40 }, + (_, index) => ` - <<: *shared\n title: tab${index}` + ).join('\n') expect( parseOrcaYaml(` -items: &items [one, two] -expanded: [${aliases}] -scripts: - setup: pnpm install +shared: &shared + command: pnpm dev +defaultTabs: +${tabs} `) - ).toBeNull() + ).toMatchObject({ + defaultTabs: expect.arrayContaining([{ title: 'tab39', command: 'pnpm dev' }]) + }) + }) + + it('rejects nested aliases that expand exponentially', () => { + // The parser rejects on uses x subtree-alias-count, so depth is what it catches: this is a few + // hundred bytes of source that would otherwise materialize millions of nodes. + let source = 'a0: &a0 [x, x, x, x, x, x, x, x, x]\n' + for (let level = 1; level <= 8; level += 1) { + source += `a${level}: &a${level} [${Array(9) + .fill(`*a${level - 1}`) + .join(', ')}]\n` + } + + expect(parseOrcaYaml(`${source}scripts:\n setup: *a8\n`)).toBeNull() }) }) diff --git a/src/shared/orca-yaml-file-limit.ts b/src/shared/orca-yaml-file-limit.ts index 8f2641637a1..be1cb2bcf70 100644 --- a/src/shared/orca-yaml-file-limit.ts +++ b/src/shared/orca-yaml-file-limit.ts @@ -5,7 +5,10 @@ export const MAX_ORCA_YAML_CODE_UNITS = 256 * 1024 export const MAX_ORCA_YAML_FIELD_BYTES = 64 * 1024 export const MAX_ORCA_YAML_FIELD_CODE_UNITS = 64 * 1024 export const MAX_ORCA_YAML_COLLECTION_ENTRIES = 256 -export const MAX_ORCA_YAML_ALIAS_COUNT = 20 +// The yaml parser rejects on `useCount * subtreeAliasCount`, so exponential expansion is caught by +// the multiplication regardless of this value; lowering it only rejects flat, linear reuse. Keep the +// library default so an orca.yaml that merges one anchor into many tabs still parses. +export const MAX_ORCA_YAML_ALIAS_COUNT = 100 export function isOrcaYamlTextWithinLimit(content: string): boolean { return ( diff --git a/src/shared/orca-yaml.ts b/src/shared/orca-yaml.ts index e8cb15e0ad8..f5abd79c1eb 100644 --- a/src/shared/orca-yaml.ts +++ b/src/shared/orca-yaml.ts @@ -31,6 +31,46 @@ export const ORCA_VM_RECIPE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/ export const ORCA_VM_RECIPE_ID_RULE = 'Use 1-64 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter or number.' +// Why: bound the work one repo file can request; entries beyond this are ignored. +const MAX_SHARED_DIRECTORIES = 100 + +/** Normalize `worktree.sharedDirectories` into deduped repo-root-relative paths. + * `\` becomes `/`, a `./` prefix and trailing `/` are stripped. Absolute paths, + * `..` traversal and `.git` are dropped here so callers get only safe entries. + * + * Entries that would still need collapsing (`apps/./web`) are dropped rather + * than rewritten: `resolve()` collapses them when the symlink is created, but + * Git reports the collapsed path, so every later comparison against the stored + * entry would miss and the link would look like permanent untracked work. */ +function normalizeSharedDirectories(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + const seen = new Set() + for (const entry of value.slice(0, MAX_SHARED_DIRECTORIES)) { + const raw = asTrimmedString(entry) + if (!raw) { + continue + } + const normalized = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') + const segments = normalized.split('/') + if ( + !normalized || + normalized.startsWith('/') || + /^[a-zA-Z]:/.test(normalized) || + segments.includes('..') || + segments.includes('.') || + segments.includes('') || + segments.includes('.git') + ) { + continue + } + seen.add(normalized) + } + return Array.from(seen) +} + function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] { if (!Array.isArray(value) || value.length > MAX_ORCA_YAML_COLLECTION_ENTRIES) { return [] @@ -180,6 +220,10 @@ export function parseOrcaYaml(content: string): OrcaHooks | null { const environmentRecipeParse = normalizeVmRecipes(record.environmentRecipes) const environmentRecipes = environmentRecipeParse.recipes const environmentRecipeDiagnostics = environmentRecipeParse.diagnostics + const worktreeRecord = asRecord(record.worktree) + const sharedDirectories = worktreeRecord + ? normalizeSharedDirectories(worktreeRecord.sharedDirectories) + : [] if ( !setup && @@ -187,7 +231,8 @@ export function parseOrcaYaml(content: string): OrcaHooks | null { !issueCommand && defaultTabs.length === 0 && environmentRecipes.length === 0 && - environmentRecipeDiagnostics.length === 0 + environmentRecipeDiagnostics.length === 0 && + sharedDirectories.length === 0 ) { return null } @@ -200,6 +245,7 @@ export function parseOrcaYaml(content: string): OrcaHooks | null { ...(issueCommand ? { issueCommand } : {}), ...(defaultTabs.length > 0 ? { defaultTabs } : {}), ...(environmentRecipes.length > 0 ? { environmentRecipes } : {}), - ...(environmentRecipeDiagnostics.length > 0 ? { environmentRecipeDiagnostics } : {}) + ...(environmentRecipeDiagnostics.length > 0 ? { environmentRecipeDiagnostics } : {}), + ...(sharedDirectories.length > 0 ? { worktree: { sharedDirectories } } : {}) } } diff --git a/src/shared/orchestration-rpc-contract.ts b/src/shared/orchestration-rpc-contract.ts index 2fb372366cc..de85ef8d8bd 100644 --- a/src/shared/orchestration-rpc-contract.ts +++ b/src/shared/orchestration-rpc-contract.ts @@ -13,6 +13,8 @@ export const ORCHESTRATION_SKILL_COMMAND_ARGS = [ '--full' ] as const +export const ORCHESTRATION_LEGACY_RUN_ID = 'run_legacy_local' + const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.runCreate', 'orchestration.runUse', diff --git a/src/shared/print-mode-headless-command.ts b/src/shared/print-mode-headless-command.ts new file mode 100644 index 00000000000..77cee14597f --- /dev/null +++ b/src/shared/print-mode-headless-command.ts @@ -0,0 +1,38 @@ +const PRINT_MODE_FLAGS = new Set(['--print', '-p']) +const HEADLESS_OUTPUT_FORMATS = new Set(['json', 'stream-json']) + +export function optionName(token: string): string { + const eq = token.indexOf('=') + return eq === -1 ? token : token.slice(0, eq) +} + +function optionValue(tokens: readonly string[], index: number): string | null { + const token = tokens[index] + const eq = token.indexOf('=') + if (eq !== -1) { + return token.slice(eq + 1) + } + return tokens[index + 1] ?? null +} + +// Why: `--print`/`-p` prints one response and exits, and `--output-format json|stream-json` +// is only meaningful there — either means a headless run, not the interactive TUI Orca hosts. +export function isPrintModeHeadlessOneShotCommand(tokens: readonly string[]): boolean { + for (let index = 1; index < tokens.length; index += 1) { + // Why: `--` ends option parsing, so a prompt that reads like `--print` is still a prompt. + if (tokens[index] === '--') { + return false + } + const name = optionName(tokens[index]) + if (PRINT_MODE_FLAGS.has(name)) { + return true + } + if (name === '--output-format') { + const value = optionValue(tokens, index)?.toLowerCase() + if (value && HEADLESS_OUTPUT_FORMATS.has(value)) { + return true + } + } + } + return false +} diff --git a/src/shared/raster-image-base64-preview.ts b/src/shared/raster-image-base64-preview.ts index 549abc360d4..b366d737218 100644 --- a/src/shared/raster-image-base64-preview.ts +++ b/src/shared/raster-image-base64-preview.ts @@ -1,7 +1,7 @@ -import type { RasterImageDimensions } from './raster-image-dimensions' +import { readRasterImageDimensions } from './raster-image-dimensions' import { - assertRasterImagePreviewWithinLimits, isKnownRasterImageMimeType, + isRasterImagePreviewDimensions, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES } from './raster-image-preview-limits' @@ -121,21 +121,24 @@ function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | nul return output.subarray(0, outputLength) } -/** Returns undefined for non-raster MIME types and null for rejected raster bytes. */ -export function readRasterImagePreviewDimensionsFromBase64( +/** + * Whether the encoded dimensions are known to exceed the preview limits. + * + * Distinct from a failed read: an unrecognized or truncated header means we could not measure the + * image, not that it is too large. Treating those the same blanks out valid images that no decoder + * has trouble with, so only a confident over-limit answer should suppress a preview. + */ +export function exceedsRasterImagePreviewLimits( content: string, mimeType: string | undefined -): RasterImageDimensions | null | undefined { +): boolean { if (!isKnownRasterImageMimeType(mimeType)) { - return undefined + return false } const prefix = decodeBase64Prefix(content, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES) if (!prefix) { - return null - } - try { - return assertRasterImagePreviewWithinLimits(prefix, mimeType) ?? null - } catch { - return null + return false } + const dimensions = readRasterImageDimensions(prefix) + return dimensions !== null && !isRasterImagePreviewDimensions(dimensions) } diff --git a/src/shared/raster-image-dimensions.test.ts b/src/shared/raster-image-dimensions.test.ts index 17e20d68da5..1a517a82791 100644 --- a/src/shared/raster-image-dimensions.test.ts +++ b/src/shared/raster-image-dimensions.test.ts @@ -20,6 +20,39 @@ function bmpHeader(width: number, height: number): Buffer { return bmp } +/** SOI, then `metadataBytes` of APP2 padding (plus `extraSegments` empty ones), then SOF0. */ +function jpegWithMetadata( + metadataBytes: number, + width: number, + height: number, + extraSegments = 0 +): Buffer { + const parts = [Buffer.from([0xff, 0xd8])] + for (let written = 0; written < metadataBytes; ) { + // A JPEG segment length field is 16 bits, so real files chain many segments to carry a profile. + const size = Math.min(65_533, metadataBytes - written) + const header = Buffer.alloc(4) + header.writeUInt16BE(0xffe2) + header.writeUInt16BE(size + 2, 2) + parts.push(header, Buffer.alloc(size)) + written += size + } + for (let index = 0; index < extraSegments; index += 1) { + const empty = Buffer.alloc(4) + empty.writeUInt16BE(0xffe2) + empty.writeUInt16BE(2, 2) + parts.push(empty) + } + const sof = Buffer.alloc(11) + sof.writeUInt16BE(0xffc0) + sof.writeUInt16BE(8, 2) + sof[4] = 8 + sof.writeUInt16BE(height, 5) + sof.writeUInt16BE(width, 7) + parts.push(sof) + return Buffer.concat(parts) +} + function icoWithPayload(payload: Buffer, width = 1, height = 1): Buffer { const header = Buffer.alloc(22) header.writeUInt16LE(1, 2) @@ -59,4 +92,17 @@ describe('readRasterImageDimensions', () => { expect(readRasterImageDimensions(truncated)).toBeNull() expect(readRasterImageDimensions(bmpHeader(0, 16))).toBeNull() }) + + it('reads a JPEG whose frame header sits behind large chained metadata', () => { + // Cameras and editors emit ICC/MPF profiles split across many 64 KiB segments, pushing SOF0 far + // into the file. Both shapes below decode everywhere, so neither may read as an unknown size. + expect(readRasterImageDimensions(jpegWithMetadata(1_400 * 1024, 4_000, 3_000))).toEqual({ + width: 4_000, + height: 3_000 + }) + expect(readRasterImageDimensions(jpegWithMetadata(0, 640, 480, 6_000))).toEqual({ + width: 640, + height: 480 + }) + }) }) diff --git a/src/shared/raster-image-dimensions.ts b/src/shared/raster-image-dimensions.ts index 4ea3c27c751..45ccd01dc2d 100644 --- a/src/shared/raster-image-dimensions.ts +++ b/src/shared/raster-image-dimensions.ts @@ -1,7 +1,7 @@ export type RasterImageDimensions = { width: number; height: number } -const JPEG_DIMENSION_SCAN_MAX_BYTES = 1024 * 1024 -const JPEG_DIMENSION_SCAN_MAX_MARKERS = 4_096 +// No scan cap: this is a forward seek over a caller-bounded buffer, so it costs O(1) memory and at +// most one pass. Capping it only made valid images with large ICC/MPF metadata unreadable. const ICO_MAX_IMAGES = 1_024 const JPEG_START_OF_FRAME_MARKERS = new Set([ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf @@ -100,15 +100,13 @@ function readJpegDimensions(bytes: Uint8Array): RasterImageDimensions | null { return null } let offset = 2 - let markersRead = 0 - const scanEnd = Math.min(bytes.byteLength, JPEG_DIMENSION_SCAN_MAX_BYTES) - while (offset < scanEnd && markersRead < JPEG_DIMENSION_SCAN_MAX_MARKERS) { + const scanEnd = bytes.byteLength + while (offset < scanEnd) { while (offset < scanEnd && bytes[offset] === 0xff) { offset += 1 } const marker = bytes[offset] offset += 1 - markersRead += 1 if (marker === undefined || marker === 0x00 || marker === 0xd9 || marker === 0xda) { return null } diff --git a/src/shared/raster-image-preview-limits.ts b/src/shared/raster-image-preview-limits.ts index a4b44d0fb69..3e187d472b9 100644 --- a/src/shared/raster-image-preview-limits.ts +++ b/src/shared/raster-image-preview-limits.ts @@ -2,7 +2,10 @@ import { readRasterImageDimensions, type RasterImageDimensions } from './raster- export const MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX = 32_768 export const MAX_RASTER_IMAGE_PREVIEW_PIXELS = 32 * 1024 * 1024 -export const RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES = 1024 * 1024 +// Bounds the transient decode buffer, not the image. 1 MiB cut off valid photos whose SOF sits past +// a large ICC/MPF block; 8 MiB clears every real-world metadata layout while staying a fraction of +// the base64 string the caller already holds. +export const RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES = 8 * 1024 * 1024 export const INVALID_RASTER_IMAGE_PREVIEW_ERROR = 'Image preview has invalid or unsupported raster dimensions' export const RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR = diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index e4caf38dac6..4daae215989 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -1,5 +1,4 @@ -import { decrypt } from './e2ee-crypto' -import { encrypt } from './e2ee-crypto' +import { decrypt, encrypt } from './e2ee-crypto' import type WebSocket from 'ws' import { RemoteRuntimeClientError } from './remote-runtime-client' import { serializeRemoteRuntimePayload } from './remote-runtime-memory-limits' diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 46679391536..62d8293bcc8 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -23,9 +23,9 @@ import type { Worktree, WorktreeLineage, WorkspaceLineage, - WorktreeLineageWarning + WorktreeLineageWarning, + TerminalPaneLayoutNode } from './types' -import type { TerminalPaneLayoutNode } from './types' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult @@ -171,6 +171,9 @@ export type RuntimeMobileSessionTerminalTab = { /** Per-tab view preference (terminal xterm vs native chat). Host-persisted so * paired clients converge; clients still win during the optimistic echo window. */ viewMode?: 'terminal' | 'chat' + /** Launch context delivered only into the TUI input as an unsent draft; the + * mobile chat composer adopts it so the context isn't invisible in chat. */ + launchDraft?: string isActive: boolean } diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 99c360a3e44..0414a1823c0 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -81,6 +81,8 @@ export type SkillFreshnessInstallation = { currentPackageDigest: string currentAppVersion: string observedPackageDigest: string | null + /** Git tree sha of the observed bytes; lets the post-run verdict match disk against the updater's lock. */ + observedGitTreeSha?: string | null errorCategory: string | null } @@ -112,8 +114,12 @@ export type SkillFreshnessScanIssue = { // every installed skill. It is still listed in Details, like the other bounds. const SKILL_SCAN_ATTENTION_REASONS = new Set(['io-error']) +export function isSkillScanAttentionReason(reason: SkillFreshnessScanIssueReason): boolean { + return SKILL_SCAN_ATTENTION_REASONS.has(reason) +} + export function isSkillScanIssueNeedingAttention(issue: SkillFreshnessScanIssue): boolean { - return SKILL_SCAN_ATTENTION_REASONS.has(issue.reason) + return isSkillScanAttentionReason(issue.reason) } // Why: these are the bounds that end the walk rather than skip one folder. They are diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index 92a4e6c90db..464a458813d 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -7,7 +7,10 @@ import { SSH_DETECTED_PORTS_MAX_ENTRIES, SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES, SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES, - SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES, + SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES, + admitSshConnectionStateForAuthorityReconciliation, + isAdmissibleDirectSshAuthority } from './ssh-retained-payload-admission' describe('SSH retained payload admission', () => { @@ -18,6 +21,7 @@ describe('SSH retained payload admission', () => { status: 'connected', error: null, reconnectAttempt: 2, + providerEpoch: 'provider-a', connectionGeneration: 3, supportsFolderDownload: true, remotePlatform: 'linux', @@ -31,12 +35,107 @@ describe('SSH retained payload admission', () => { status: 'connected', error: null, reconnectAttempt: 2, + providerEpoch: 'provider-a', connectionGeneration: 3, supportsFolderDownload: true, remotePlatform: 'linux' }) }) + it('rejects partial and malformed provider authority', () => { + const state = { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + expect(admitSshConnectionState({ ...state, providerEpoch: 'provider-a' }, 'ssh-a')).toBeNull() + expect(admitSshConnectionState({ ...state, connectionGeneration: 3 }, 'ssh-a')).toBeNull() + expect( + admitSshConnectionState( + { + ...state, + providerEpoch: 'x'.repeat(SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES + 1), + connectionGeneration: 3 + }, + 'ssh-a' + ) + ).toBeNull() + }) + + it('admits only bounded complete direct SSH authority', () => { + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'ssh-a', + providerEpoch: 'provider-a', + connectionGeneration: 3 + }) + ).toBe(true) + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'ssh-a', + providerEpoch: 'provider-a' + }) + ).toBe(false) + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1), + providerEpoch: 'provider-a', + connectionGeneration: 3 + }) + ).toBe(false) + expect( + isAdmissibleDirectSshAuthority({ + targetId: 'ssh-a', + providerEpoch: 'x'.repeat(SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES + 1), + connectionGeneration: 3 + }) + ).toBe(false) + }) + + it('normalizes only partial authority for bounded reconciliation', () => { + const state = { + targetId: 'ssh-a', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + + expect( + admitSshConnectionStateForAuthorityReconciliation( + { ...state, providerEpoch: 'provider-a' }, + 'ssh-a' + ) + ).toEqual({ ...state, providerEpoch: null }) + expect( + admitSshConnectionStateForAuthorityReconciliation( + { ...state, providerEpoch: '', connectionGeneration: 3 }, + 'ssh-a' + ) + ).toBeNull() + }) + + it('normalizes legacy authority to unknown', () => { + expect( + admitSshConnectionState( + { + targetId: 'ssh-a', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + }, + 'ssh-a' + ) + ).toEqual({ + targetId: 'ssh-a', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + providerEpoch: null + }) + }) + it('caps connection errors without splitting a UTF-8 code point', () => { const admitted = admitSshConnectionState( { diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts index 21b51787a1d..cc173c577d5 100644 --- a/src/shared/ssh-retained-payload-admission.ts +++ b/src/shared/ssh-retained-payload-admission.ts @@ -1,8 +1,15 @@ -import type { EnrichedDetectedPort, SshConnectionState, SshConnectionStatus } from './ssh-types' +import type { + DirectSshAuthority, + EnrichedDetectedPort, + SshConnectionState, + SshConnectionStatus, + SshProviderEpoch +} from './ssh-types' import { clampUtf8TextPrefix, measureUtf8ByteLength } from './utf8-byte-limits' export const SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES = 1024 export const SSH_CONNECTION_ERROR_MAX_UTF8_BYTES = 16 * 1024 +export const SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES = 128 export const SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES = 16 * 1024 export const SSH_DETECTED_PORTS_MAX_ENTRIES = 50 export const SSH_DETECTED_PORT_HOST_MAX_UTF8_BYTES = 1024 @@ -30,6 +37,18 @@ export function isSshRetainedIdentifier(value: unknown): value is string { ) } +export function isAdmissibleDirectSshAuthority(value: unknown): value is DirectSshAuthority { + if (!value || typeof value !== 'object') { + return false + } + const authority = value as Record + return ( + isSshRetainedIdentifier(authority.targetId) && + isSshProviderEpoch(authority.providerEpoch) && + isNonNegativeSafeInteger(authority.connectionGeneration) + ) +} + export function admitSshConnectionState( value: unknown, expectedTargetId: string @@ -50,14 +69,23 @@ export function admitSshConnectionState( } const error = clampSshConnectionError(input.error) + const hasProviderEpoch = input.providerEpoch !== undefined && input.providerEpoch !== null + const hasConnectionGeneration = input.connectionGeneration !== undefined + if ( + hasProviderEpoch !== hasConnectionGeneration || + (hasProviderEpoch && + (!isSshProviderEpoch(input.providerEpoch) || + !isNonNegativeSafeInteger(input.connectionGeneration))) + ) { + return null + } return { targetId: expectedTargetId, status: input.status as SshConnectionStatus, error, reconnectAttempt: input.reconnectAttempt, - ...(isNonNegativeSafeInteger(input.connectionGeneration) - ? { connectionGeneration: input.connectionGeneration } - : {}), + providerEpoch: hasProviderEpoch ? (input.providerEpoch as SshProviderEpoch) : null, + ...(hasProviderEpoch ? { connectionGeneration: input.connectionGeneration as number } : {}), ...(typeof input.supportsFolderDownload === 'boolean' ? { supportsFolderDownload: input.supportsFolderDownload } : {}), @@ -69,6 +97,43 @@ export function admitSshConnectionState( } } +export function admitSshConnectionStateForAuthorityReconciliation( + value: unknown, + expectedTargetId: string +): SshConnectionState | null { + const admitted = admitSshConnectionState(value, expectedTargetId) + if (admitted || !value || typeof value !== 'object') { + return admitted + } + const input = value as Record + const hasProviderEpoch = input.providerEpoch !== undefined && input.providerEpoch !== null + const hasConnectionGeneration = input.connectionGeneration !== undefined + if (hasProviderEpoch === hasConnectionGeneration) { + return null + } + return admitSshConnectionState( + { + targetId: input.targetId, + status: input.status, + error: input.error, + reconnectAttempt: input.reconnectAttempt, + supportsFolderDownload: input.supportsFolderDownload, + remotePlatform: input.remotePlatform + }, + expectedTargetId + ) +} + +function isSshProviderEpoch(value: unknown): value is SshProviderEpoch { + return ( + typeof value === 'string' && + value.length > 0 && + !measureUtf8ByteLength(value, { + stopAfterBytes: SSH_PROVIDER_EPOCH_MAX_UTF8_BYTES + }).exceededLimit + ) +} + export function clampSshConnectionError(error: string | null): string | null { return typeof error === 'string' ? clampUtf8TextPrefix(error, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) diff --git a/src/shared/ssh-types.test.ts b/src/shared/ssh-types.test.ts index 247a6bd7ba7..53f60b54476 100644 --- a/src/shared/ssh-types.test.ts +++ b/src/shared/ssh-types.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { SshTarget, SshConnectionState, SshConnectionStatus } from './ssh-types' +import type { + SshTarget, + SshConnectionState, + SshConnectionStatus, + SshProviderEpoch +} from './ssh-types' describe('SSH types', () => { it('SshTarget has required fields', () => { @@ -33,10 +38,14 @@ describe('SSH types', () => { targetId: 'target-1', status: 'connected', error: null, - reconnectAttempt: 0 + reconnectAttempt: 0, + providerEpoch: 'provider-a' as SshProviderEpoch, + connectionGeneration: 1 } expect(state.status).toBe('connected') expect(state.error).toBeNull() + expect(state.providerEpoch).toBe('provider-a') + expect(state.connectionGeneration).toBe(1) }) it('Repo.connectionId is optional for backward compatibility', () => { diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 26c09c5e2a7..889a15256f1 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -111,12 +111,22 @@ export type SshConnectionStatus = export type SshRemotePlatform = 'linux' | 'darwin' | 'win32' +export type SshProviderEpoch = string & { readonly __sshProviderEpoch: unique symbol } + +export type DirectSshAuthority = { + targetId: string + providerEpoch: SshProviderEpoch + connectionGeneration: number +} + export type SshConnectionState = { targetId: string status: SshConnectionStatus error: string | null /** Number of reconnection attempts since last disconnect. */ reconnectAttempt: number + /** Opaque provider-incarnation token issued by main. */ + providerEpoch?: SshProviderEpoch | null /** Non-secret owner token used to reject mutations captured for an obsolete SSH session. */ connectionGeneration?: number /** Folder downloads require ssh2 SFTP and are unavailable on system SSH. */ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 9cbfaac20e2..b333b4a30f2 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -94,6 +94,7 @@ export const AGENT_KIND_VALUES = [ 'grok', 'devin', 'ante', + 'trae', 'other' ] as const export const agentKindSchema = z.enum(AGENT_KIND_VALUES) @@ -1299,6 +1300,62 @@ const editorExternalChangeConflictActionSchema = z }) .strict() +const directSshReconnectCountSchema = z.number().int().min(0).max(1_000_000) +const directSshReconnectDurationSchema = z.number().int().min(0).max(86_400_000) +const directSshReconnectOperationSchema = z + .object({ + mode: z.enum(['reconnect', 'prepare_only']), + reason: z.enum(['reconnect', 'initial_hydration', 'workspace_snapshot', 'wake_refresh']), + outcome: z.enum(['complete', 'degraded', 'canceled', 'stale', 'stopped', 'stabilizing']), + terminal_retried_count: directSshReconnectCountSchema, + terminal_stale_binding_cleared_count: directSshReconnectCountSchema, + terminal_correction_succeeded_count: directSshReconnectCountSchema, + catalog_complete_count: directSshReconnectCountSchema, + catalog_degraded_count: directSshReconnectCountSchema, + catalog_stale_count: directSshReconnectCountSchema, + repo_complete_count: directSshReconnectCountSchema, + repo_non_authoritative_count: directSshReconnectCountSchema, + repo_retrying_count: directSshReconnectCountSchema, + repo_timed_out_count: directSshReconnectCountSchema, + repo_cancel_budget_exhausted_count: directSshReconnectCountSchema, + repo_canceled_count: directSshReconnectCountSchema, + repo_stale_count: directSshReconnectCountSchema, + repo_rejected_count: directSshReconnectCountSchema, + lineage_complete_count: directSshReconnectCountSchema, + lineage_degraded_count: directSshReconnectCountSchema, + lineage_canceled_count: directSshReconnectCountSchema, + lineage_stale_count: directSshReconnectCountSchema, + lineage_not_started_count: directSshReconnectCountSchema, + git_worktree_count: directSshReconnectCountSchema, + folder_workspace_count: directSshReconnectCountSchema, + ambiguous_owner_count: directSshReconnectCountSchema, + contradictory_owner_count: directSshReconnectCountSchema, + total_duration_ms: directSshReconnectDurationSchema, + terminal_finalization_duration_ms: directSshReconnectDurationSchema, + catalog_duration_ms: directSshReconnectDurationSchema, + queue_wait_sample_count: directSshReconnectCountSchema, + queue_wait_duration_ms_p50: directSshReconnectDurationSchema, + queue_wait_duration_ms_p95: directSshReconnectDurationSchema, + queue_wait_duration_ms_p99: directSshReconnectDurationSchema, + queue_wait_duration_ms_max: directSshReconnectDurationSchema, + provider_execution_sample_count: directSshReconnectCountSchema, + provider_execution_duration_ms_p50: directSshReconnectDurationSchema, + provider_execution_duration_ms_p95: directSshReconnectDurationSchema, + provider_execution_duration_ms_p99: directSshReconnectDurationSchema, + provider_execution_duration_ms_max: directSshReconnectDurationSchema, + timeout_retry_count: directSshReconnectCountSchema, + locally_settled_waiter_count: directSshReconnectCountSchema, + cancel_debt_count: directSshReconnectCountSchema, + replacement_admission_delayed_count: directSshReconnectCountSchema, + overlapping_join_count: directSshReconnectCountSchema, + coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: + directSshReconnectCountSchema.max(5), + estimated_late_work_allowance_count: directSshReconnectCountSchema.max(2), + authority_rotation_count: directSshReconnectCountSchema, + damped_preparation_count: directSshReconnectCountSchema + }) + .strict() + // ── Event registry: the one record the validator consumes ─────────────── // Versioning: breaking changes (rename/re-mean/remove a key) need a new event name; in-place edits blend pre/post rows unmixably. Additive-optional fields are safe. export const eventSchemas = { @@ -1387,6 +1444,8 @@ export const eventSchemas = { editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema, editor_external_change_conflict_action: editorExternalChangeConflictActionSchema, + direct_ssh_reconnect_operation: directSshReconnectOperationSchema, + smart_sort_class_distribution: smartSortClassDistributionSchema, smart_sort_class_1_promotion: smartSortClass1PromotionSchema, smart_to_recent_switch: smartToRecentSwitchSchema diff --git a/src/shared/terminal-control-stripping.ts b/src/shared/terminal-control-stripping.ts new file mode 100644 index 00000000000..2f843e92630 --- /dev/null +++ b/src/shared/terminal-control-stripping.ts @@ -0,0 +1,73 @@ +const ESC = String.fromCharCode(0x1b) +const BEL = String.fromCharCode(0x07) +const ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, + 'g' +) +const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, + 'g' +) +const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64 +const CONTROL_DENSITY_FALLBACK_COUNT = 32 + +function isStrippedTerminalControl(code: number): boolean { + return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f) +} + +export function stripTerminalControl(data: string): string { + if (!terminalControlMayAffectText(data)) { + return data + } + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + // Four calls per PTY chunk favor copying sparse intact runs over per-character concatenation. + let output = '' + let runStart = 0 + let strippedInBlock = 0 + let blockEnd = CONTROL_DENSITY_BLOCK_CODE_UNITS + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (index === blockEnd) { + strippedInBlock = 0 + blockEnd += CONTROL_DENSITY_BLOCK_CODE_UNITS + } + if (isStrippedTerminalControl(withoutAnsi.charCodeAt(index))) { + if (index > runStart) { + output += withoutAnsi.slice(runStart, index) + } + runStart = index + 1 + strippedInBlock += 1 + if (strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT) { + let tailOutput = '' + for (let tailIndex = runStart; tailIndex < withoutAnsi.length; tailIndex += 1) { + const tailCode = withoutAnsi.charCodeAt(tailIndex) + // Inlined isStrippedTerminalControl: this tail runs per code unit on the shape that + // already lost to the call overhead. Keep the two copies in sync. + if ( + (tailCode <= 0x1f && tailCode !== 0x0a && tailCode !== 0x0d) || + (tailCode >= 0x7f && tailCode <= 0x9f) + ) { + continue + } + tailOutput += withoutAnsi[tailIndex] + } + return output + tailOutput + } + } + } + return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) +} + +function terminalControlMayAffectText(data: string): boolean { + for (let index = 0; index < data.length; index += 1) { + const code = data.charCodeAt(index) + if ( + code === 0x0d || + code === 0x1b || + (code <= 0x1f && code !== 0x0a) || + (code >= 0x7f && code <= 0x9f) + ) { + return true + } + } + return false +} diff --git a/src/shared/timer-delay.test.ts b/src/shared/timer-delay.test.ts index 23171a60e99..7f94b33a655 100644 --- a/src/shared/timer-delay.test.ts +++ b/src/shared/timer-delay.test.ts @@ -33,17 +33,30 @@ describe('timer delay policy', () => { expect(parsePositiveSafeIntegerText(raw)).toBe(expected) }) - it.each(['', '0', '-1', '1.5', '.1', '1e-1', '9007199254740991.1', '9007199254740992'])( - 'rejects inexact or unsafe integer text %s', - (raw) => { - expect(parsePositiveSafeIntegerText(raw)).toBeNull() - } - ) + it.each([ + '', + '0', + '-1', + '1.5', + '.1', + '1e-1', + '1.0000000000000000001', + '+1.0000000000000000001', + '9007199254740991.1', + '9007199254740992' + ])('rejects inexact or unsafe integer text %s', (raw) => { + expect(parsePositiveSafeIntegerText(raw)).toBeNull() + }) + // Values the CLI's own Number() coercion accepts must parse to the same + // budget here, or the caller's timer expires before the CLI's does. it.each([ ['+1000', 1_000], ['1000.0', 1_000], - ['1e3', 1_000] + ['1e3', 1_000], + ['1.0000000000000000001', 1], + ['+1.0000000000000000001', 1], + ['600000.000000000000001', 600_000] ])('parses CLI-compatible positive integer text %s', (raw, expected) => { expect(parsePositiveSafeIntegerNumericText(raw)).toBe(expected) }) diff --git a/src/shared/timer-delay.ts b/src/shared/timer-delay.ts index 99172b99f45..5f57bb4c10e 100644 --- a/src/shared/timer-delay.ts +++ b/src/shared/timer-delay.ts @@ -19,6 +19,12 @@ export function parsePositiveSafeIntegerText(raw: string): number | null { return exactValue === BigInt(value) ? value : null } +// Why: mirrors the CLI's own `Number()` coercion for generic `--timeout-ms` +// flags (cli/flags.ts getOptionalPositiveIntegerFlag). Text that coerces to an +// exact integer — `1000.0`, `600000.000000000000001` — is the budget the CLI +// will actually wait on, so rejecting it here would leave the caller's timer +// shorter than the CLI's and cut the request short. Callers that need exact +// text (orchestration ask) use parsePositiveSafeIntegerText instead. export function parsePositiveSafeIntegerNumericText(raw: string): number | null { const value = Number(raw) return Number.isSafeInteger(value) && value > 0 ? value : null diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 4405d106162..49e07d41839 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -96,6 +96,18 @@ export const TUI_AGENT_CONFIG: Record = { // Why: `ante --prompt` is headless (runs once and exits), so launch the bare TUI and inject after startup. promptInjectionMode: 'stdin-after-start' }, + trae: { + // Why: the unrelated open-source bytedance/trae-agent also installs a `trae-cli` + // binary, so detect TRAE CN's CLI on `traecli`, an alias only TRAE CN ships. + detectCmd: 'traecli', + launchCmd: 'traecli', + expectedProcess: 'traecli', + // Why: `traecli [prompt]` takes the task as a positional argv, same as Claude/Codex. + promptInjectionMode: 'argv', + // Why: separator so prompts starting with `help`/`config`/`-…` aren't parsed as a + // Trae subcommand or flag — `--` stops both in its Cobra parser. + argvPromptSeparator: '--' + }, opencode: { detectCmd: 'opencode', launchCmd: 'opencode', diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 4d62d35f961..f680e16f967 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -12,6 +12,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { codex: 'Codex', devin: 'Devin', ante: 'Ante', + trae: 'Trae', autohand: 'Autohand Code', opencode: 'OpenCode', 'mimo-code': 'MiMo Code', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 9833bfca193..ae0c34391a2 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -27,7 +27,8 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { copilot: '--yolo', grok: '--permission-mode bypassPermissions', devin: '--permission-mode bypass', - ante: '--yolo' + ante: '--yolo', + trae: '--yolo' } export const YOLO_TUI_AGENT_ENV: Partial>> = { diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 7ac9d252713..48e10305aa5 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -13,6 +13,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'opencode', 'mimo-code', 'ante', + 'trae', 'pi', 'omp', 'gemini', diff --git a/src/shared/types.ts b/src/shared/types.ts index c1faa77ab4c..dbe2c034010 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2097,6 +2097,13 @@ export type OrcaHooks = { defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes environmentRecipeDiagnostics?: OrcaVmRecipeDiagnostic[] // Non-fatal validation issues from environmentRecipes + worktree?: OrcaWorktreeDefaults // Project-scoped defaults applied when a worktree is created +} + +export type OrcaWorktreeDefaults = { + // Why: shared (symlinked) rather than copied — large rebuildable dirs like + // node_modules should be one install serving every worktree. + sharedDirectories?: string[] } export type OrcaDefaultTabTemplate = { @@ -2530,6 +2537,7 @@ export type TuiAgent = | 'grok' // xAI Grok CLI | 'devin' // Devin CLI | 'ante' // Ante (Antigma Labs) + | 'trae' // Trae CLI export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' @@ -3271,6 +3279,7 @@ export type PersistedUIState = { rightSidebarExplorerView: RightSidebarExplorerView rightSidebarWidth: number markdownTocPanelWidth?: number + combinedDiffFileTreeWidth?: number groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' /** Project header ordering in `groupBy: 'repo'`, independent of `sortBy`: 'manual' uses persisted order + header drag, 'recent' by latest visible activity. */ diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 1b04a05282f..a14523c4d8f 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -12,9 +12,12 @@ import { type AgentScratchWorktreePathMatcher } from './agent-scratch-worktrees' import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from './external-worktree-visibility' import type { DetectedWorktree, - ExternalWorktreeVisibility, GlobalSettings, OrcaWorkspaceLayout, Repo, @@ -23,30 +26,11 @@ import type { WorktreeOwnership } from './types' -export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23) - -export function isLegacyRepoForExternalWorktreeVisibility(repo: Repo): boolean { - if (typeof repo.externalWorktreeVisibilityLegacy === 'boolean') { - return repo.externalWorktreeVisibilityLegacy - } - if (repo.externalWorktreeVisibility === undefined) { - return true - } - if (!Number.isFinite(repo.addedAt)) { - return true - } - return repo.addedAt < EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT -} - -export function effectiveExternalWorktreeVisibility( - repo: Pick, - isLegacyRepoForVisibility: boolean -): ExternalWorktreeVisibility { - if (repo.externalWorktreeVisibility) { - return repo.externalWorktreeVisibility - } - return isLegacyRepoForVisibility ? 'show' : 'hide' -} +export { + effectiveExternalWorktreeVisibility, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + isLegacyRepoForExternalWorktreeVisibility +} from './external-worktree-visibility' export function buildKnownOrcaWorkspaceLayouts( settings: Pick, diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts index 8b7cdf4cb87..18e98409094 100644 --- a/tests/e2e/agent-session-live-force-exit-resume.spec.ts +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -183,27 +183,33 @@ test('resumes a live agent record after force-exit restart when pane PTY ownersh const descriptor = await waitForActivePaneHookDescriptor(page) const ptyId = await waitForActivePanePtyId(page) + const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath) const marker = `AGENT_LIVE_FORCE_EXIT_${Date.now()}` await execInTerminal(page, ptyId, `echo ${marker}`) await waitForTerminalOutput(page, marker) await page.evaluate( - ({ paneKey, worktreeId: wtId, providerSessionId }) => { - window.__store - ?.getState() - .setAgentStatus( - paneKey, - { state: 'working', prompt: 'finish the task', agentType: 'codex' }, - 'Codex', - undefined, - { worktreeId: wtId }, - { providerSession: { key: 'session_id', id: providerSessionId } } - ) + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store?.getState().setAgentStatus( + paneKey, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { + providerSession: { + key: 'session_id', + id: providerSessionId, + transcriptPath + } + } + ) }, { paneKey: descriptor.paneKey, worktreeId: descriptor.worktreeId, - providerSessionId: PROVIDER_SESSION_ID + providerSessionId: PROVIDER_SESSION_ID, + transcriptPath } ) diff --git a/tests/e2e/agent-session-quit-resume.spec.ts b/tests/e2e/agent-session-quit-resume.spec.ts index 49cd1c6d792..17c68f01ca7 100644 --- a/tests/e2e/agent-session-quit-resume.spec.ts +++ b/tests/e2e/agent-session-quit-resume.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -14,9 +14,42 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-quit-resume-session' +function stubPersistedResumeCommand(userDataDir: string): void { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + providerSession?: { id?: unknown } + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } + } + > + } + } + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected a persisted resumable agent session') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') +} + function readDaemonPid(userDataDir: string): number { const raw = readFileSync( path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), @@ -58,6 +91,7 @@ test('resumes an agent session after quit when its daemon PTY died while the app const marker = `AGENT_QUIT_RESUME_${Date.now()}` const descriptor = await waitForActivePaneHookDescriptor(page) const firstPtyId = await waitForActivePanePtyId(page) + const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath) await execInTerminal(page, firstPtyId, `echo ${marker}`) await waitForTerminalOutput(page, marker) @@ -65,22 +99,27 @@ test('resumes an agent session after quit when its daemon PTY died while the app // server; seeding the same store entry keeps this test hermetic (no agent // CLI install or auth) while exercising the identical persistence path. await page.evaluate( - ({ paneKey, worktreeId: wtId, providerSessionId }) => { - window.__store - ?.getState() - .setAgentStatus( - paneKey, - { state: 'working', prompt: 'finish the task', agentType: 'codex' }, - 'Codex', - undefined, - { worktreeId: wtId }, - { providerSession: { key: 'session_id', id: providerSessionId } } - ) + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store?.getState().setAgentStatus( + paneKey, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { + providerSession: { + key: 'session_id', + id: providerSessionId, + transcriptPath + } + } + ) }, { paneKey: descriptor.paneKey, worktreeId: descriptor.worktreeId, - providerSessionId: PROVIDER_SESSION_ID + providerSessionId: PROVIDER_SESSION_ID, + transcriptPath } ) @@ -88,6 +127,7 @@ test('resumes an agent session after quit when its daemon PTY died while the app await session.close(firstApp) firstApp = null + stubPersistedResumeCommand(session.userDataDir) // Why: simulates the daemon (and the agent CLI inside it) dying while the // app is closed — reboot, crash, or update kill. SIGKILL leaves history diff --git a/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts b/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts new file mode 100644 index 00000000000..169c95e0631 --- /dev/null +++ b/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts @@ -0,0 +1,171 @@ +/** + * E2E regression for issue #11090: in a narrow browser pane every toolbar + * button stays shrink-0, so the address bar absorbed the whole squeeze and + * became an unusable globe icon with a zero-width input. Focusing it must now + * overlay the toolbar with a typable field that navigates on Enter. + */ + +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { expect, test } from './helpers/orca-app' +import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test' +import { + ensureTerminalVisible, + getActiveTabType, + getActiveWorktreeId, + getBrowserTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH } from '../../src/renderer/src/components/browser-pane/browser-address-bar-expansion' + +// Why: the toolbar must land in a band — squeezed enough that the inline field +// collapses, roomy enough that the overlay itself has somewhere to go. Target +// the middle of that band rather than a fixed window width, because how much +// chrome flanks the pane (left sidebar, and a right sidebar that other startup +// paths may re-open) varies between runs. +const TARGET_TOOLBAR_WIDTH = 420 +const MIN_USABLE_TOOLBAR_WIDTH = BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH + 100 +const NARROW_WINDOW_HEIGHT = 800 + +async function startDestinationServer(): Promise<{ url: string; close: () => Promise }> { + const server = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end( + 'Typed destinationok' + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/typed`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } +} + +async function createBlankBrowserTab(page: Page, worktreeId: string): Promise { + await page.evaluate((targetWorktreeId) => { + window.__store?.getState().createBrowserTab(targetWorktreeId, 'about:blank', { + title: 'Narrow toolbar tab', + activate: true + }) + }, worktreeId) + await expect.poll(async () => getActiveTabType(page), { timeout: 10_000 }).toBe('browser') +} + +async function setWindowWidth(electronApp: ElectronApplication, width: number): Promise { + await electronApp.evaluate( + ({ BrowserWindow }, size) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.setSize(size.width, size.height) + }, + { width: Math.round(width), height: NARROW_WINDOW_HEIGHT } + ) +} + +function browserToolbar(page: Page): Locator { + return page.locator('[data-contextual-tour-target="browser-toolbar"]').first() +} + +async function toolbarWidth(page: Page): Promise { + return browserToolbar(page).evaluate((node) => Math.round(node.getBoundingClientRect().width)) +} + +function addressBarInput(page: Page): Locator { + return page.locator('[data-orca-browser-address-bar="true"]') +} + +function addressBarOverlay(page: Page): Locator { + return page.locator('[data-orca-browser-address-bar-overlay="true"]') +} + +async function addressBarInputWidth(page: Page): Promise { + return addressBarInput(page).evaluate((node) => node.getBoundingClientRect().width) +} + +/** + * Drive the app to the resting state this spec is about: a squeezed-but-usable + * toolbar whose address bar is collapsed and unfocused. + * + * Why one loop rather than three: both preconditions are actively fought by the + * app. Startup paths re-open the right sidebar (which alone leaves the pane + * ~70px, too narrow for the overlay to have anywhere to go), and BrowserPane + * re-focuses a blank tab's address bar across several animation frames plus the + * blank-url did-finish-load handler. Settling them separately just lets whichever + * settled first drift back while the next one runs, so re-assert all of them + * together until they hold at the same time. The interval must clear the 200ms + * blur-close timer in BrowserAddressBar for the collapse to register. + */ +async function settleToSqueezedRestingState( + page: Page, + electronApp: ElectronApplication +): Promise { + await expect + .poll( + async () => { + await page.evaluate(() => { + window.__store?.getState().setRightSidebarOpen(false) + }) + const [innerWidth, toolbar] = await Promise.all([ + page.evaluate(() => window.innerWidth), + toolbarWidth(page) + ]) + // Chrome flanking the pane is everything the toolbar didn't get. + await setWindowWidth(electronApp, innerWidth - toolbar + TARGET_TOOLBAR_WIDTH) + await addressBarInput(page).evaluate((node) => node.blur()) + return { + toolbar: (await toolbarWidth(page)) > MIN_USABLE_TOOLBAR_WIDTH, + collapsed: (await addressBarOverlay(page).count()) === 0 + } + }, + { timeout: 30_000, intervals: [300, 300, 300, 500, 500, 1000] } + ) + .toEqual({ toolbar: true, collapsed: true }) +} + +test.describe('Browser address bar in a narrow toolbar', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('focusing the squeezed address bar expands a typable field that navigates', async ({ + orcaPage, + electronApp + }) => { + const destination = await startDestinationServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + await createBlankBrowserTab(orcaPage, worktreeId) + await settleToSqueezedRestingState(orcaPage, electronApp) + + const overlay = addressBarOverlay(orcaPage) + // The bug: the inline field is squeezed away entirely. + await expect.poll(() => addressBarInputWidth(orcaPage), { timeout: 10_000 }).toBeLessThan(40) + + await orcaPage.locator('form:has(> [data-orca-browser-address-bar="true"])').click() + + await expect(overlay).toBeVisible() + await expect + .poll(() => addressBarInputWidth(orcaPage), { timeout: 5_000 }) + .toBeGreaterThan(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH / 2) + + await addressBarInput(orcaPage).fill(destination.url) + await addressBarInput(orcaPage).press('Enter') + + await expect + .poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).at(-1)?.url ?? null, { + timeout: 15_000 + }) + .toContain('/typed') + } finally { + await destination.close() + } + }) +}) diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 137bc48dee1..3702ad21a9f 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -69,7 +69,7 @@ process.stdin.on('data', (chunk) => { cleanup() } - pending += chunk + pending += chunk.toString() let match let lastIndex = 0 let reportsInChunk = 0 diff --git a/tests/e2e/helpers/docker-ssh-relay-connection.ts b/tests/e2e/helpers/docker-ssh-relay-connection.ts index 59f696042c1..4c603365ac5 100644 --- a/tests/e2e/helpers/docker-ssh-relay-connection.ts +++ b/tests/e2e/helpers/docker-ssh-relay-connection.ts @@ -51,10 +51,24 @@ export async function connectDockerSshRelayTarget( if (!state || state.status !== 'connected') { throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) } + if ( + !state.providerEpoch || + !Number.isSafeInteger(state.connectionGeneration) || + state.connectionGeneration === undefined || + state.connectionGeneration < 0 + ) { + throw new Error(`SSH target returned incomplete authority: ${JSON.stringify(state)}`) + } store.getState().setSshConnectionState(createdTarget.id, state) const labels = new Map(store.getState().sshTargetLabels) labels.set(createdTarget.id, createdTarget.label) store.getState().setSshTargetLabels(labels) + const executionHostId = `ssh:${encodeURIComponent(createdTarget.id)}` as const + const authority = { + targetId: createdTarget.id, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } const result = await window.api.repos.addRemote({ connectionId: createdTarget.id, @@ -64,9 +78,71 @@ export async function connectDockerSshRelayTarget( if ('error' in result) { throw new Error(result.error) } + const hasExpectedRepoOwner = (): boolean => + store + .getState() + .repos.some( + (repo) => + repo.id === result.repo.id && + repo.connectionId === createdTarget.id && + repo.executionHostId === executionHostId + ) + const waitForRepoOwner = async (): Promise => { + if (hasExpectedRepoOwner()) { + return + } + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + unsubscribe() + reject(new Error(`Remote repo owner did not hydrate for ${result.repo.path}`)) + }, 15_000) + const unsubscribe = store.subscribe((next) => { + if ( + !next.repos.some( + (repo) => + repo.id === result.repo.id && + repo.connectionId === createdTarget.id && + repo.executionHostId === executionHostId + ) + ) { + return + } + window.clearTimeout(timer) + unsubscribe() + resolve() + }) + }) + } await store.getState().fetchRepos() - await store.getState().fetchWorktrees(result.repo.id) - const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] + await waitForRepoOwner() + const currentState = store.getState().sshConnectionStates.get(createdTarget.id) + if ( + currentState?.providerEpoch !== authority.providerEpoch || + currentState.connectionGeneration !== authority.connectionGeneration + ) { + throw new Error(`SSH authority rotated before worktree hydration for ${result.repo.path}`) + } + const worktreeResult = await store.getState().fetchWorktrees(result.repo.id, { + executionHostId, + directSshAuthority: authority, + requireAuthoritative: true + }) + if ( + worktreeResult.status !== 'complete' || + worktreeResult.repoId !== result.repo.id || + worktreeResult.authority.kind !== 'direct-ssh' || + worktreeResult.authority.executionHostId !== executionHostId || + worktreeResult.authority.targetId !== authority.targetId || + worktreeResult.authority.providerEpoch !== authority.providerEpoch || + worktreeResult.authority.connectionGeneration !== authority.connectionGeneration + ) { + throw new Error( + `Remote worktree hydration was not authoritative: ${JSON.stringify(worktreeResult)}` + ) + } + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? []).find( + (candidate) => candidate.hostId === executionHostId + ) if (!worktree) { throw new Error(`No remote worktree found for ${result.repo.path}`) } diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index a503508cd46..609ff1797bc 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -16,7 +16,7 @@ import { type TestInfo } from '@stablyai/playwright-test' import { execSync } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:net' import os from 'node:os' import path from 'node:path' @@ -48,6 +48,7 @@ type LaunchOptions = { type RestartSession = { userDataDir: string + seedCodexResumeRollout: (sessionId: string, cwd: string) => string launch: (options?: LaunchOptions) => Promise /** Gracefully close a launch, letting beforeunload flush session state. */ close: (app: ElectronApplication) => Promise @@ -149,6 +150,28 @@ export function createRestartSession( `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` ) + const seedCodexResumeRollout = (sessionId: string, cwd: string): string => { + const sessionsDir = path.join( + homeIsolation.isolatedHome, + '.codex', + 'sessions', + '2026', + '07', + '28' + ) + mkdirSync(sessionsDir, { recursive: true }) + const transcriptPath = path.join(sessionsDir, `rollout-2026-07-28T00-00-00-${sessionId}.jsonl`) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-07-28T00:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd } + })}\n` + ) + return transcriptPath + } + const launch = async (options?: LaunchOptions): Promise => { runtimeWsPort ??= await reserveRestartRuntimeWsPort() const app = await electron.launch({ @@ -193,7 +216,7 @@ export function createRestartSession( } } - return { userDataDir, launch, close, dispose } + return { userDataDir, seedCodexResumeRollout, launch, close, dispose } } /** diff --git a/tests/e2e/helpers/plugin-panel-navigation-observer.ts b/tests/e2e/helpers/plugin-panel-navigation-observer.ts new file mode 100644 index 00000000000..8f8415b4313 --- /dev/null +++ b/tests/e2e/helpers/plugin-panel-navigation-observer.ts @@ -0,0 +1,108 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import type { Event as ElectronEvent, WebContentsWillFrameNavigateEventParams } from 'electron' + +export type PanelNavigationObservation = { + willFrameNavigations: { defaultPrevented: boolean; isMainFrame: boolean; url: string }[] + didFrameNavigations: { isMainFrame: boolean; url: string }[] + externalUrls: string[] +} + +type MainPanelNavigationProbe = { + dispose: () => void + observation: PanelNavigationObservation +} + +export async function startPanelNavigationObserver( + electronApp: ElectronApplication, + pageUrl: string +): Promise { + await electronApp.evaluate(({ BrowserWindow, shell }, expectedUrl) => { + const browserWindow = + BrowserWindow.getAllWindows().find( + (candidate) => candidate.webContents.getURL() === expectedUrl + ) ?? BrowserWindow.getAllWindows()[0] + if (!browserWindow) { + throw new Error('main window unavailable for panel navigation observer') + } + const contents = browserWindow.webContents + const observation: PanelNavigationObservation = { + willFrameNavigations: [], + didFrameNavigations: [], + externalUrls: [] + } + const onWillFrameNavigate = ( + event: ElectronEvent + ): void => { + observation.willFrameNavigations.push({ + defaultPrevented: event.defaultPrevented, + isMainFrame: event.isMainFrame, + url: event.url + }) + } + const onDidFrameNavigate = ( + _event: ElectronEvent, + url: string, + _httpResponseCode: number, + _httpStatusText: string, + isMainFrame: boolean + ): void => { + observation.didFrameNavigations.push({ isMainFrame, url }) + } + const probeGlobal = globalThis as typeof globalThis & { + __orcaPanelNavigationProbe?: MainPanelNavigationProbe + } + probeGlobal.__orcaPanelNavigationProbe?.dispose() + const originalOpenExternal = shell.openExternal + const recordOpenExternal = async (url: string): Promise => { + observation.externalUrls.push(url) + } + contents.on('will-frame-navigate', onWillFrameNavigate) + contents.on('did-frame-navigate', onDidFrameNavigate) + shell.openExternal = recordOpenExternal + + probeGlobal.__orcaPanelNavigationProbe = { + observation, + dispose: () => { + contents.off('will-frame-navigate', onWillFrameNavigate) + contents.off('did-frame-navigate', onDidFrameNavigate) + if (shell.openExternal === recordOpenExternal) { + shell.openExternal = originalOpenExternal + } + } + } + }, pageUrl) +} + +export async function readPanelNavigationObserver( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { + __orcaPanelNavigationProbe?: MainPanelNavigationProbe + } + ).__orcaPanelNavigationProbe + if (!probe) { + throw new Error('panel navigation observer is not active') + } + return structuredClone(probe.observation) + }) +} + +export async function stopPanelNavigationObserver( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(() => { + const probeGlobal = globalThis as typeof globalThis & { + __orcaPanelNavigationProbe?: MainPanelNavigationProbe + } + const probe = probeGlobal.__orcaPanelNavigationProbe + if (!probe) { + throw new Error('panel navigation observer is not active') + } + const observation = structuredClone(probe.observation) + probe.dispose() + delete probeGlobal.__orcaPanelNavigationProbe + return observation + }) +} diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts index 5500bb223a2..f8308e62a17 100644 --- a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -69,10 +69,11 @@ async function openComposerWithLinkedWorkItem( async function switchComposerProject(page: Page, projectName: string): Promise { const composer = page.getByRole('dialog') - const combobox = composer.locator('button[data-project-combobox-root="true"]') + const combobox = composer.getByRole('combobox', { name: 'Project' }) + const comboboxRoot = composer.locator('div[data-project-combobox-root="true"]') await combobox.click() await page.getByRole('option', { name: new RegExp(projectName) }).click() - await expect(combobox).toContainText(projectName) + await expect(comboboxRoot).toContainText(projectName) } test.describe('New workspace composer linked item across project switches', () => { diff --git a/tests/e2e/orchestration-worker-terminal-visibility.spec.ts b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts new file mode 100644 index 00000000000..23d1adf68d8 --- /dev/null +++ b/tests/e2e/orchestration-worker-terminal-visibility.spec.ts @@ -0,0 +1,159 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test as base, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + switchToOtherWorktree, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActivePanePtyId } from './helpers/terminal' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' + +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-orchestration-worker-')) +const fakeCodexSource = ` +if (process.argv.slice(2).includes('app-server')) { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n') +let acknowledged = false +process.stdin.on('data', (chunk) => { + if (!acknowledged && chunk.toString().includes('\\r')) { + acknowledged = true + process.stdout.write('ACK\\n') + } +}) +process.stdin.resume() +setInterval(() => {}, 60_000) +` + +if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) +} else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) +} + +const test = base.extend({ + launchEnv: [ + { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}` + }, + { option: true } + ] +}) + +test.afterAll(() => { + rmSync(fakeCliDir, { recursive: true, force: true }) +}) + +test('worker-start materializes one inactive terminal tab before workspace re-entry', async ({ + orcaPage, + electronApp +}) => { + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const coordinatorTabId = await getActiveTabId(orcaPage) + expect(coordinatorTabId).toBeTruthy() + await waitForActivePanePtyId(orcaPage) + const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const coordinator = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey: coordinatorPane.paneKey + }) + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Verify worker terminal visibility', + from: coordinator.result.terminal.handle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Respond ACK and remain idle', + run: run.result.run.id, + callerTerminalHandle: coordinator.result.terminal.handle + }) + const coordinatorTerminal = await client.call<{ terminal: { worktreeId: string } }>( + 'terminal.show', + { terminal: coordinator.result.terminal.handle } + ) + await expect + .poll(async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + return listed.result.worktrees.some( + (worktree) => worktree.id === coordinatorTerminal.result.terminal.worktreeId + ) + }) + .toBe(true) + + const started = await client.call<{ + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinator.result.terminal.handle, + agent: 'codex', + timeoutMs: 15_000 + }) + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + expect(workerHandle).toBeTruthy() + const workerTabTitle = `worker-${task.result.task.id}` + + const terminals = await client.call('terminal.list') + const workerTerminal = terminals.result.terminals.find( + (terminal) => terminal.title === 'Codex Ready' + ) + expect(workerTerminal?.tabId).toBeTruthy() + expect(workerTerminal?.leafId).toBeTruthy() + await expect + .poll(async () => { + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: workerTerminal!.handle, + limit: 200 + }) + return read.result.terminal.tail.join('\n') + }) + .toContain('ACK') + const workerTab = orcaPage.locator( + `[data-testid="sortable-tab"][data-tab-id="${workerTerminal!.tabId}"]` + ) + await expect(workerTab).toBeVisible() + await expect(workerTab).toHaveAttribute('data-active', 'false') + await expect( + orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${coordinatorTabId}"]`) + ).toHaveAttribute('data-active', 'true') + + await client.call('orchestration.send', { + from: workerHandle, + to: `run:${run.result.run.id}`, + subject: 'ACK' + }) + const checked = await client.call<{ messages: { subject: string }[] }>('orchestration.check', { + terminal: 'term_stale_coordinator', + terminalPaneKey: coordinatorPane.paneKey + }) + expect(checked.result.messages).toEqual([expect.objectContaining({ subject: 'ACK' })]) + + const otherWorktreeId = await switchToOtherWorktree(orcaPage, worktreeId) + expect(otherWorktreeId).toBeTruthy() + await expect(workerTab).not.toBeVisible() + await switchToWorktree(orcaPage, worktreeId) + + await expect(workerTab).toBeVisible() + await expect( + orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${workerTerminal!.tabId}"]`) + ).toHaveCount(1) + await expect( + orcaPage.locator(`[data-testid="sortable-tab"][data-tab-title="${workerTabTitle}"]`) + ).toHaveCount(1) +}) diff --git a/tests/e2e/plugin-panel-containment.spec.ts b/tests/e2e/plugin-panel-containment.spec.ts index 19f64bb8722..1f7061767fa 100644 --- a/tests/e2e/plugin-panel-containment.spec.ts +++ b/tests/e2e/plugin-panel-containment.spec.ts @@ -1,7 +1,7 @@ /** - * Invariant: a plugin panel cannot exfiltrate, navigate, or bypass bridge budgets. + * Invariant: a plugin panel cannot exfiltrate, navigate, or bypass the host bridge. * Oracle: a permissive loopback server receives zero requests while the real - * sandboxed iframe reports CSP/navigation containment and actual budget refusals. + * sandboxed iframe reports CSP/navigation containment and a bounded bridge refusal. * Chromium is required because Vitest cannot exercise CSP or iframe sandboxing. * Maturity: experimental until this has CI soak history on all desktop platforms. */ @@ -13,6 +13,12 @@ import { join } from 'node:path' import type { AddressInfo } from 'node:net' import type { ElectronApplication, FrameLocator, Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' +import { + readPanelNavigationObserver, + startPanelNavigationObserver, + stopPanelNavigationObserver, + type PanelNavigationObservation +} from './helpers/plugin-panel-navigation-observer' type InstalledPanel = { pluginKey: string @@ -188,7 +194,8 @@ async function inspectElectronFrameProcesses( }, pageUrl) } -test('contains hostile panel network, navigation, and bridge-flood probes', async ({ +test('contains hostile panel network and navigation probes', async ({ + electronApp, orcaPage }, testInfo) => { testInfo.annotations.push({ type: 'maturity', description: 'experimental' }) @@ -198,6 +205,9 @@ test('contains hostile panel network, navigation, and bridge-flood probes', asyn const appUrl = orcaPage.url() const browserEvents: string[] = [] const panelDocuments: PanelDocumentSnapshot[] = [] + const replacedNavigations: { destinations: string[]; probe: string }[] = [] + let navigationObservation: PanelNavigationObservation | null = null + let navigationProbeStarted = false orcaPage.on('console', (message) => { browserEvents.push(`console:${message.type()}:${message.text()}`) }) @@ -238,52 +248,185 @@ test('contains hostile panel network, navigation, and bridge-flood probes', asyn ) } - await frame.getByRole('button', { name: 'Run bridge budget probes' }).click() - for (const probe of ['oversized-message', 'message-flood']) { - await expect(frame.locator(`[data-probe="${probe}"]`)).toHaveAttribute( - 'data-contained', - 'true', - { timeout: 5_000 } - ) - } + const bridgeErrorCode = await frame.locator('html').evaluate( + () => + new Promise((resolve, reject) => { + const requestId = 'small-invalid-probe' + const timer = setTimeout(() => reject(new Error('host sent no bridge refusal')), 5_000) + const onMessage = (event: MessageEvent): void => { + const data = event.data + if ( + event.source !== window.parent || + !data || + data.type !== 'orca-panel-action-result' || + data.requestId !== requestId + ) { + return + } + clearTimeout(timer) + window.removeEventListener('message', onMessage) + resolve(data.errorCode ?? 'missing_error_code') + } + window.addEventListener('message', onMessage) + window.parent.postMessage( + { + type: 'orca-panel-action', + requestId, + action: 'invalid.hostileAction', + params: {} + }, + '*' + ) + }) + ) + expect(bridgeErrorCode).toBe('invalid_request') expect(server.requests).toEqual([]) expect(orcaPage.url()).toBe(appUrl) await expect(iframe).toBeVisible() + await startPanelNavigationObserver(electronApp, appUrl) + navigationProbeStarted = true const initialDocument = await readPanelDocument(frame) panelDocuments.push(initialDocument) for (const navigation of [ - { button: 'Try top navigation', probe: 'top-navigation' }, - { button: 'Try self navigation', probe: 'self-navigation' }, - { button: 'Try anchor and form navigation', probe: 'anchor-form-navigation' }, - { button: 'Try meta refresh navigation', probe: 'meta-refresh-navigation' } + { + button: 'Try top navigation', + destinations: [`${server.origin}/`], + probe: 'top-navigation' + }, + { + button: 'Try self navigation', + destinations: [`${server.origin}/self-navigation`], + probe: 'self-navigation' + }, + { + button: 'Try anchor and form navigation', + destinations: [`${server.origin}/anchor-navigation`, `${server.origin}/form-navigation`], + probe: 'anchor-form-navigation' + }, + { + button: 'Try meta refresh navigation', + destinations: [`${server.origin}/meta-refresh`], + probe: 'meta-refresh-navigation' + } ]) { - await frame.getByRole('button', { name: navigation.button }).click() - await expect(frame.locator(`[data-probe="${navigation.probe}"]`)).toHaveAttribute( - 'data-contained', - 'true', - { timeout: 5_000 } + const sourceDocumentId = `source:${navigation.probe}` + const button = frame.getByRole('button', { name: navigation.button }) + await button.evaluate((element, documentId) => { + element.ownerDocument.documentElement.dataset.navigationProbeDocument = documentId + const navigationButton = element as HTMLButtonElement + navigationButton.click() + }, sourceDocumentId) + const outcome = await frame.locator('html').evaluate( + (element, expected) => { + const result = element.querySelector(`[data-probe="${expected.probe}"]`) + return { + contained: result?.getAttribute('data-contained') ?? null, + invocationCount: element.querySelectorAll( + `meta[data-navigation-probe-invoked="${expected.probe}"][content="true"]` + ).length, + retained: element.dataset.navigationProbeDocument === expected.documentId + } + }, + { + documentId: sourceDocumentId, + probe: navigation.probe + } ) + expect(outcome.contained === null || outcome.contained === 'true').toBe(true) + if (outcome.retained) { + expect(outcome.invocationCount).toBe(1) + } else { + replacedNavigations.push(navigation) + } const currentDocument = await readPanelDocument(frame) panelDocuments.push(currentDocument) expect(currentDocument.url).toBe(initialDocument.url) expect(currentDocument.html).toContain('Hostile panel fixture') + if (outcome.retained && navigation.probe === 'anchor-form-navigation') { + await expect(frame.locator(`a[href="${navigation.destinations[0]}"]`)).toHaveCount(1) + await expect(frame.locator(`form[action="${navigation.destinations[1]}"]`)).toHaveCount(1) + } + if (outcome.retained && navigation.probe === 'meta-refresh-navigation') { + await expect(frame.locator('meta[http-equiv="refresh"]')).toHaveAttribute( + 'content', + `0;url=${navigation.destinations[0]}` + ) + } expect(server.requests).toEqual([]) expect(orcaPage.url()).toBe(appUrl) } + const guardDestination = `${server.origin}/frame-guard-navigation` + await iframe.evaluate((element, destination) => { + const panelWindow = (element as HTMLIFrameElement).contentWindow + if (!panelWindow) { + throw new Error('plugin panel window unavailable') + } + panelWindow.location.href = destination + }, guardDestination) + await expect + .poll(async () => { + navigationObservation = await readPanelNavigationObserver(electronApp) + const attempt = navigationObservation.willFrameNavigations.find( + ({ url }) => url === guardDestination + ) + return attempt?.defaultPrevented === true && attempt.isMainFrame === false + }) + .toBe(true) + const guardedDocument = await readPanelDocument(frame) + panelDocuments.push(guardedDocument) + expect(guardedDocument.url).toBe(initialDocument.url) + expect(guardedDocument.html).toContain('Hostile panel fixture') + await expect(frame.locator('html')).toHaveAttribute( + 'data-navigation-probe-document', + 'source:meta-refresh-navigation' + ) + expect(server.requests).toEqual([]) + expect(orcaPage.url()).toBe(appUrl) + + navigationObservation = await readPanelNavigationObserver(electronApp) + const attemptedProbeNavigations = navigationObservation.willFrameNavigations.filter(({ url }) => + url.startsWith(server.origin) + ) + expect(attemptedProbeNavigations.length).toBeGreaterThan(0) + expect(attemptedProbeNavigations.every(({ defaultPrevented }) => defaultPrevented)).toBe(true) + for (const navigation of replacedNavigations) { + expect( + navigation.destinations.every((destination) => + attemptedProbeNavigations.some( + (attempt) => attempt.url === destination && attempt.defaultPrevented + ) + ), + `${navigation.probe} replacement must follow an authoritative blocked navigation` + ).toBe(true) + } + expect( + navigationObservation.didFrameNavigations.filter(({ url }) => url.startsWith(server.origin)) + ).toEqual([]) + expect(navigationObservation.externalUrls).toEqual([]) } finally { - await attachProbeRequests(testInfo, server.requests) - await testInfo.attach('hostile-panel-browser-events', { - body: Buffer.from(browserEvents.join('\n')), - contentType: 'text/plain' - }) - await testInfo.attach('hostile-panel-documents', { - body: Buffer.from(JSON.stringify(panelDocuments, null, 2)), - contentType: 'application/json' - }) - await server.close() - await rm(tempRoot, { recursive: true, force: true }) + try { + if (navigationProbeStarted) { + navigationObservation = await stopPanelNavigationObserver(electronApp) + } + } finally { + await attachProbeRequests(testInfo, server.requests) + await testInfo.attach('hostile-panel-browser-events', { + body: Buffer.from(browserEvents.join('\n')), + contentType: 'text/plain' + }) + await testInfo.attach('hostile-panel-documents', { + body: Buffer.from(JSON.stringify(panelDocuments, null, 2)), + contentType: 'application/json' + }) + await testInfo.attach('hostile-panel-navigation-observation', { + body: Buffer.from(JSON.stringify(navigationObservation, null, 2)), + contentType: 'application/json' + }) + await server.close() + await rm(tempRoot, { recursive: true, force: true }) + } } }) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index 231dd43cd2c..1d7606fcbe3 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -11,6 +11,7 @@ import { import { cleanupDockerSshRelayTarget, DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, startDockerSshRelayTarget, type DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' @@ -273,15 +274,12 @@ test.describe('Docker SSH relay perf', () => { const runId = String(Date.now()) // Large remote binaries: each read streams ~8MB of fs.streamChunk frames // over the same SSH channel that carries the pty echo. - const loadFiles = [ - `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-a.png`, - `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-b.png` - ] + const loadFile = `/tmp/orca-relay-load-${runId}.png` + const loadFiles = [loadFile, loadFile] await execInTerminal( orcaPage, ptyId, - `dd if=/dev/urandom of=${shellQuote(loadFiles[0])} bs=1M count=8 status=none && ` + - `dd if=/dev/urandom of=${shellQuote(loadFiles[1])} bs=1M count=8 status=none && ` + + `dd if=/dev/urandom of=${shellQuote(loadFile)} bs=1M count=8 status=none && ` + `echo LOAD_FILES_READY_${runId}` ) await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) @@ -376,8 +374,16 @@ test.describe('Docker SSH relay perf', () => { await waitForActiveTerminalManager(orcaPage, 60_000) const afterPtyId = await waitForActivePanePtyId(orcaPage, 60_000) const afterMarker = `SSH_RECONNECT_AFTER_${Date.now()}` - await execInTerminal(orcaPage, afterPtyId, `printf ${shellQuote(afterMarker)}`) + const remoteProofPath = `/tmp/${afterMarker}` + await execInTerminal( + orcaPage, + afterPtyId, + `printf ${shellQuote(afterMarker)} | tee ${shellQuote(remoteProofPath)}` + ) await waitForTerminalOutput(orcaPage, afterMarker, 20_000, 60_000) + expect(execDockerSshRelayTargetCommand(target, `cat ${shellQuote(remoteProofPath)}`)).toBe( + afterMarker + ) testInfo.annotations.push({ type: 'docker-ssh-reconnect', diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index a0ced05497f..9d23fe6002c 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -1,5 +1,4 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { realpathSync } from 'node:fs' +import { mkdirSync, writeFileSync, realpathSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' diff --git a/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts b/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts index d4b5a9c6ca5..d6a2a89a899 100644 --- a/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts +++ b/tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts @@ -797,10 +797,10 @@ test.describe('OpenCode alt-screen reveal artifacts (STA-2694)', () => { // otherwise outlast xterm's one-second safety watchdog. const otherWorktreeId = await freezeMidFrameAndSwitchWorktree(orcaPage, setup) test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide') - expect( - await readSynchronizedOutputLatch(orcaPage, setup.tabId), + test.skip( + !(await readSynchronizedOutputLatch(orcaPage, setup.tabId)), 'the watchdog cleared the latch before the hide, so this run proves nothing' - ).toBe(true) + ) await orcaPage.waitForTimeout(2_500) await switchToWorktree(orcaPage, setup.worktreeId) await activateTerminalTab(orcaPage, setup.tabId) diff --git a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts index 12cf89319d2..c14b9580b90 100644 --- a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts +++ b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts @@ -56,11 +56,27 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl expect(await getWorktreeTabs(firstLaunch.page, worktreeId)).toHaveLength(1) const client = new RuntimeClient(session.userDataDir, 30_000) - const active = await client.call<{ handle: string }>('terminal.resolveActive', { - worktree: `id:${worktreeId}` - }) + let activeHandle: string | null = null + await expect + .poll( + async () => { + const listed = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + const matching = listed.result.terminals.filter( + (terminal) => terminal.worktreeId === worktreeId && terminal.tabId === closedTabId + ) + activeHandle = matching.length === 1 ? (matching[0]?.handle ?? null) : null + return matching.length + }, + { message: 'Closed-tab candidate did not become uniquely runtime-visible' } + ) + .toBe(1) + if (!activeHandle) { + throw new Error('Closed-tab candidate became visible without a terminal handle') + } const split = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { - terminal: active.result.handle, + terminal: activeHandle, direction: 'vertical' }) expect(split.result.split.tabId).toBe(closedTabId) @@ -80,12 +96,19 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl }) .toEqual([]) - const afterClose = await client.call('terminal.list', { - worktree: `id:${worktreeId}` - }) - expect( - afterClose.result.terminals.filter((terminal) => terminal.tabId === closedTabId) - ).toEqual([]) + await expect + .poll( + async () => { + const afterClose = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + return afterClose.result.terminals + .filter((terminal) => terminal.tabId === closedTabId) + .map((terminal) => terminal.handle) + }, + { message: 'The acknowledged close left host terminal rows alive' } + ) + .toEqual([]) await session.close(firstApp) firstApp = null diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 3c980756e8e..ffb602686da 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -152,6 +152,81 @@ test.describe('Create Workspace', () => { } }) + test('creates an emoji-named worktree with a safe git branch', async ({ orcaPage }) => { + const workspaceName = '🚀🧪✨' + + try { + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + await expect(dialog).toBeVisible() + await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible() + + const nameInput = dialog.getByPlaceholder(/Type a name/i) + await nameInput.fill(workspaceName) + + const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }) + await expect(createButton).toBeEnabled() + await createButton.click() + + await expect(dialog).toBeHidden({ timeout: 15_000 }) + await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({ + timeout: 10_000 + }) + + const branch = await orcaPage.evaluate((displayName) => { + const worktrees = Object.values(window.__store!.getState().worktreesByRepo).flat() + return worktrees.find((worktree) => worktree.displayName === displayName)?.branch ?? null + }, workspaceName) + expect(branch).toBe('refs/heads/rocket-test-tube-sparkles') + } finally { + await orcaPage + .evaluate(() => { + window.__store?.getState().closeModal() + }) + .catch(() => { + /* page may already be torn down */ + }) + } + }) + + test('enters emoji with Slack-style shortcode suggestions', async ({ orcaPage }) => { + try { + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const nameInput = dialog.getByPlaceholder(/Type a name/i) + await expect(nameInput).toBeVisible() + + await nameInput.pressSequentially('Launch :wink', { delay: 100 }) + const emojiSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') + const sourceSuggestions = orcaPage.locator('[data-workspace-source-suggestions="true"]') + await expect(emojiSuggestions).toBeVisible() + await expect(emojiSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await expect(emojiSuggestions).toHaveAttribute('data-side', 'top') + await expect(sourceSuggestions).toBeVisible() + await expect(sourceSuggestions).toHaveAttribute('data-side', 'bottom') + // Keep both independently positioned suggestion surfaces visible in proof recordings. + await orcaPage.waitForTimeout(750) + + await nameInput.pressSequentially(':') + await expect(nameInput).toHaveValue('Launch 😉') + await expect(orcaPage.getByRole('option', { name: /:wink:/i })).toHaveCount(0) + await nameInput.pressSequentially(' experiment') + await expect(nameInput).toHaveValue('Launch 😉 experiment') + // Keep the asserted result visible in retained proof recordings. + await orcaPage.waitForTimeout(750) + } finally { + await orcaPage + .evaluate(() => { + window.__store?.getState().closeModal() + }) + .catch(() => { + /* page may already be torn down */ + }) + } + }) + test('shows a failed workspace entry when worktree creation fails', async ({ orcaPage }) => { await orcaPage.evaluate(() => { const store = window.__store