From 43645e52744da0d18be7e280a738fc88435beb4b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:09:36 -0400 Subject: [PATCH 01/58] feat(push): carry a notification's workspace server to the phone (#26516) A desktop relays alerts for agents on its paired servers, but the push named only the server's worktree, so a tap opened it on the desktop and bounced. The gateway accepts an optional runtime execution host and forwards it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- cloud/apps/push/src/push-delivery-message.ts | 4 +++ .../src/push-execution-host-routing.test.ts | 35 +++++++++++++++++++ .../push-contract/src/send-messages.ts | 8 ++++- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 cloud/apps/push/src/push-execution-host-routing.test.ts diff --git a/cloud/apps/push/src/push-delivery-message.ts b/cloud/apps/push/src/push-delivery-message.ts index 3bd2dae6e7cf..1d37f6215732 100644 --- a/cloud/apps/push/src/push-delivery-message.ts +++ b/cloud/apps/push/src/push-delivery-message.ts @@ -6,6 +6,7 @@ export type PushOrcaData = { hostFingerprint: string worktreeId?: string paneKey?: string + executionHost?: string notificationId?: string notificationSeq: number notificationEpoch: string @@ -54,6 +55,9 @@ export function buildPushDelivery(input: { hostFingerprint, ...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }), ...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }), + ...(notification.executionHost === undefined + ? {} + : { executionHost: notification.executionHost }), ...(notification.notificationId === undefined ? {} : { notificationId: notification.notificationId }), diff --git a/cloud/apps/push/src/push-execution-host-routing.test.ts b/cloud/apps/push/src/push-execution-host-routing.test.ts new file mode 100644 index 000000000000..fcc688e6bcc5 --- /dev/null +++ b/cloud/apps/push/src/push-execution-host-routing.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js' + +const base = { + notificationSeq: 1, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + worktreeId: 'repo-1::/srv/work' +} + +it('carries the workspace server to APNs and FCM, and accepts older messages without one', () => { + const executionHost = 'runtime:env%201' + for (const extra of [{}, { executionHost }]) { + const delivery = buildPushDelivery({ + notification: PushNotificationSchema.parse({ ...base, ...extra }), + hostFingerprint: 'host', + registrationId: 'phone', + expiresAt: Date.now() + 300000 + }) + const expected = 'executionHost' in extra ? executionHost : undefined + expect(delivery.orca.executionHost).toBe(expected) + expect(orcaDataStrings(delivery.orca).executionHost).toBe(expected) + } +}) + +it('accepts only runtime execution hosts', () => { + const invalid = ['local', 'ssh:box', 'runtime:', 'runtime:a|b', `runtime:${'x'.repeat(2048)}`] + for (const executionHost of invalid) { + expect(PushNotificationSchema.safeParse({ ...base, executionHost }).success).toBe(false) + } +}) diff --git a/cloud/packages/push-contract/src/send-messages.ts b/cloud/packages/push-contract/src/send-messages.ts index a8959c18b050..61e4c1b91664 100644 --- a/cloud/packages/push-contract/src/send-messages.ts +++ b/cloud/packages/push-contract/src/send-messages.ts @@ -27,7 +27,13 @@ export const PushNotificationSchema = z title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars), body: z.string().max(PUSH_LIMITS.bodyMaxChars), worktreeId: z.string().min(1).max(2048).optional(), - paneKey: z.string().min(1).max(2048).optional() + paneKey: z.string().min(1).max(2048).optional(), + // The workspace's server; mirrors the app's runtime host id (cannot import src/shared). + executionHost: z + .string() + .max(2048) + .regex(/^runtime:[^|\s]+$/) + .optional() }) .strict() .refine( From 76687bf4ffb0661abdec31c0a9d8256a6e8c9658 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:21:50 -0400 Subject: [PATCH 02/58] ci(relay-staging): let the staging load ask for asia-east2 from a hosted runner (#27393) The Asia load runner is registered to the old repository, so asia-east2 load could not run. Where the load comes from does not change what c4 does with it. --- .github/workflows/cloud-prove-relay-asia-staging.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cloud-prove-relay-asia-staging.yml b/.github/workflows/cloud-prove-relay-asia-staging.yml index 4f521dd86b94..60dd278c9efc 100644 --- a/.github/workflows/cloud-prove-relay-asia-staging.yml +++ b/.github/workflows/cloud-prove-relay-asia-staging.yml @@ -26,11 +26,13 @@ on: required: false type: string load-region: - description: Load only. asia-east2 runs on the Asia load runner; us-central1 on a hosted runner + description: >- + Load only. asia-east2 runs on the Asia load runner; asia-east2-hosted asks for asia-east2 + from a hosted runner; us-central1 runs on a hosted runner required: false default: asia-east2 type: choice - options: [asia-east2, us-central1] + options: [asia-east2, asia-east2-hosted, us-central1] load-shards: description: Load only. Generator processes, each with its own shard of the 32 load principals required: false @@ -364,7 +366,7 @@ jobs: # free-form staging load lives here rather than in a workflow of its own. load: if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'load' }} - runs-on: ${{ inputs.load-region == 'us-central1' && 'ubuntu-latest' || fromJSON('["self-hosted","linux","x64","relay-asia-east2-load"]') }} + runs-on: ${{ inputs.load-region == 'asia-east2' && fromJSON('["self-hosted","linux","x64","relay-asia-east2-load"]') || 'ubuntu-latest' }} timeout-minutes: 180 environment: staging env: @@ -383,7 +385,7 @@ jobs: run: | set -euo pipefail test "${CONFIRMATION}" = LOAD_RELAY_STAGING - [[ "${LOAD_REGION}" =~ ^(asia-east2|us-central1)$ ]] + [[ "${LOAD_REGION}" =~ ^(asia-east2|asia-east2-hosted|us-central1)$ ]] [[ "${LOAD_SHARDS}" =~ ^[1-4]$ ]] read -r -a args <<< "${LOAD_ARGS}" for arg in "${args[@]}"; do @@ -445,7 +447,7 @@ jobs: node dev/scripts/load-relay-controls.mjs \ --director-origin "${DIRECTOR_ORIGIN}" \ --auth-origin "${AUTH_ORIGIN}" \ - --preferred-region "${LOAD_REGION}" \ + --preferred-region "${LOAD_REGION%-hosted}" \ --relay-asia-load-principals 32 \ --shard-count "${LOAD_SHARDS}" \ --shard-index "${shard}" \ From b241ba5c01381c2d1c64a78857306e0a0b2d7c7d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:37:15 -0400 Subject: [PATCH 03/58] test(ci): point server-scope qualification fixtures at SSH after relay deletion (#27392) The relay server deletion removed src/relay/ from the cross-host qualification prefixes, but the scope tests merged alongside it still used src/relay/ paths as their cross-host example. Use src/main/ssh/, which still carries that rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../scripts/node-server-change-scope.test.mjs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 7ec12414807e..36e7031dc805 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -338,21 +338,21 @@ it.each([ }) const registry = 'config/scripts/vitest-node-runtime-files.mjs' -const unrelatedRelayUnit = 'src/relay/agent-status-store-relay-context.test.ts' +const unrelatedSshUnit = 'src/main/ssh/pinned-runtime-materializer-context.test.ts' function qualificationTree(extra = {}) { return moduleTree({ 'entry.ts': `import './${registry}'; throw Error('never execute')`, [registry]: 'export const NODE_RUNTIME_INCLUDE = []', - [unrelatedRelayUnit]: 'throw Error("never execute unrelated test")', + [unrelatedSshUnit]: 'throw Error("never execute unrelated test")', ...extra }) } -it('keeps a Node harness smoke without widening it for an unrelated modified relay unit', async () => { +it('keeps a Node harness smoke without widening it for an unrelated modified SSH unit', async () => { const root = qualificationTree() writeFileSync(join(root, registry), 'export const NODE_RUNTIME_INCLUDE = ["new.unit.test.ts"]') - const changed = [registry, unrelatedRelayUnit] + const changed = [registry, unrelatedSshUnit] const scope = await classifyNodeServerChanges( changed, () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }), @@ -378,7 +378,7 @@ it.each([ ] ])('retains daemon platform coverage: %s', async (daemon, runners, qualification) => { const root = qualificationTree({ [daemon]: 'throw Error("unselected")' }) - const diff = [registry, daemon, unrelatedRelayUnit] + const diff = [registry, daemon, unrelatedSshUnit] const g = await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) expect(g.has(registry)).toBe(true) expect(g.has(daemon)).toBe(false) @@ -387,13 +387,13 @@ it.each([ expect(nodeServerQualification(diff, s)).toEqual({ qualification, runners }) }) -it('keeps every host when the modified relay production module is an actual consumer', async () => { - const relay = 'src/relay/agent-status-store.ts' +it('keeps every host when the modified SSH production module is an actual consumer', async () => { + const ssh = 'src/main/ssh/pinned-runtime-materializer.ts' const root = qualificationTree({ - 'entry.ts': `import './${registry}'; import './${relay}'; throw Error('never execute')`, - [relay]: 'export const store = 1' + 'entry.ts': `import './${registry}'; import './${ssh}'; throw Error('never execute')`, + [ssh]: 'export const materializer = 1' }) - const changed = [registry, relay, unrelatedRelayUnit] + const changed = [registry, ssh, unrelatedSshUnit] const scope = await classifyNodeServerChanges( changed, () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }), @@ -410,13 +410,13 @@ it.each(['added', 'deleted', 'renamed', 'unknown', 'duplicate', 'foreign'])( 'retains complete-path qualification when modification evidence is %s', async (status) => { const root = qualificationTree() - const changed = [registry, unrelatedRelayUnit] + const changed = [registry, unrelatedSshUnit] let modifiedFiles = [registry] if (status === 'deleted' || status === 'renamed') { - rmSync(join(root, unrelatedRelayUnit)) + rmSync(join(root, unrelatedSshUnit)) } if (status === 'renamed') { - const renamed = 'src/relay/renamed-context.test.ts' + const renamed = 'src/main/ssh/renamed-context.test.ts' writeFileSync(join(root, renamed), 'throw Error("never execute renamed test")') changed.push(renamed) } @@ -424,7 +424,7 @@ it.each(['added', 'deleted', 'renamed', 'unknown', 'duplicate', 'foreign'])( modifiedFiles = [registry, registry] } if (status === 'foreign') { - modifiedFiles = [registry, 'src/relay/foreign.test.ts'] + modifiedFiles = [registry, 'src/main/ssh/foreign.test.ts'] } const scope = await classifyNodeServerChanges( changed, @@ -442,7 +442,7 @@ it.each(['added', 'deleted', 'renamed', 'unknown', 'duplicate', 'foreign'])( it('retains protected server tests and broken imports despite complete modification evidence', async () => { const protectedTest = 'src/main/sqlite/example.test.ts' const root = qualificationTree({ [protectedTest]: 'export const value = 1' }) - const changed = [registry, protectedTest, unrelatedRelayUnit] + const changed = [registry, protectedTest, unrelatedSshUnit] const collect = () => collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) const protectedScope = await classifyNodeServerChanges(changed, collect, { modifiedFiles: changed @@ -452,7 +452,7 @@ it('retains protected server tests and broken imports despite complete modificat runners: NODE_SERVER_RUNNERS }) writeFileSync(join(root, 'entry.ts'), `import './${registry}'; import './missing'`) - const brokenChanged = [registry, unrelatedRelayUnit] + const brokenChanged = [registry, unrelatedSshUnit] const brokenScope = await classifyNodeServerChanges(brokenChanged, collect, { modifiedFiles: brokenChanged }) From 305474cebaec5a13ee4d0c0286e58e92ec7f4d58 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:48:37 -0400 Subject: [PATCH 04/58] ci(relay-staging): split the staging load across two hosted runners (#27402) One hosted runner tops out near 2,048 connections to one cell (4 shards each stalled at 512 with control_open_timeout), short of the 2,840 the Asia flip-back test needs. --- .../cloud-prove-relay-asia-staging.yml | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cloud-prove-relay-asia-staging.yml b/.github/workflows/cloud-prove-relay-asia-staging.yml index 60dd278c9efc..ad94ee002791 100644 --- a/.github/workflows/cloud-prove-relay-asia-staging.yml +++ b/.github/workflows/cloud-prove-relay-asia-staging.yml @@ -39,6 +39,14 @@ on: default: '4' type: choice options: ['1', '2', '3', '4'] + load-runners: + description: >- + Load only, hosted runners only. Runner machines, each with load-shards generators; one + hosted runner holds about 2,000 connections to one cell + required: false + default: '1' + type: choice + options: ['1', '2'] load-args: description: >- Load only. Per-shard generator arguments, e.g. --controls 150 --splices 15 @@ -367,6 +375,11 @@ jobs: load: if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'load' }} runs-on: ${{ inputs.load-region == 'asia-east2' && fromJSON('["self-hosted","linux","x64","relay-asia-east2-load"]') || 'ubuntu-latest' }} + strategy: + # One runner's failure must not drop the other half of the load mid-run. + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.load-runners == '2' && '[0, 1]' || '[0]') }} timeout-minutes: 180 environment: staging env: @@ -374,6 +387,8 @@ jobs: AUTH_ORIGIN: https://auth-staging.onorca.dev LOAD_REGION: ${{ inputs.load-region }} LOAD_SHARDS: ${{ inputs.load-shards }} + LOAD_RUNNERS: ${{ inputs.load-runners }} + LOAD_RUNNER_INDEX: ${{ matrix.runner }} LOAD_ARGS: ${{ inputs.load-args }} steps: - uses: actions/checkout@v4 @@ -387,6 +402,12 @@ jobs: test "${CONFIRMATION}" = LOAD_RELAY_STAGING [[ "${LOAD_REGION}" =~ ^(asia-east2|asia-east2-hosted|us-central1)$ ]] [[ "${LOAD_SHARDS}" =~ ^[1-4]$ ]] + [[ "${LOAD_RUNNERS}" =~ ^[12]$ ]] + # The Asia runner is one machine; a second matrix job would only queue behind the first. + if test "${LOAD_RUNNERS}" != 1 && test "${LOAD_REGION}" = asia-east2; then + echo "load-runners 2 needs a hosted runner" >&2 + exit 1 + fi read -r -a args <<< "${LOAD_ARGS}" for arg in "${args[@]}"; do [[ "${arg}" =~ ^[A-Za-z0-9=:/._,-]+$ ]] || { echo "unsupported characters in load-args" >&2; exit 1; } @@ -449,8 +470,8 @@ jobs: --auth-origin "${AUTH_ORIGIN}" \ --preferred-region "${LOAD_REGION%-hosted}" \ --relay-asia-load-principals 32 \ - --shard-count "${LOAD_SHARDS}" \ - --shard-index "${shard}" \ + --shard-count "$((LOAD_SHARDS * LOAD_RUNNERS))" \ + --shard-index "$((LOAD_RUNNER_INDEX * LOAD_SHARDS + shard))" \ "${args[@]}" \ > "${load_dir}/shard-${shard}.jsonl" & pids+=("$!") @@ -470,7 +491,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: relay-staging-load-${{ github.run_id }}-${{ github.run_attempt }} + name: relay-staging-load-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.runner }} path: ${{ runner.temp }}/relay-staging-load/ if-no-files-found: warn retention-days: 7 From c37ce7add23bdfa03b390491fe3ef39c4ef36231 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:51:24 -0700 Subject: [PATCH 05/58] fix(zsh): keep deferred line-init defined for plugins that call it by name (#26842) * fix(zsh): keep deferred line-init defined for plugins that call it by name Fixes #26029 Co-Authored-By: petersindex * test(zsh): keep the named-call regression test out of the relay module --------- Co-authored-by: m4air Co-authored-by: petersindex --- .../daemon-zsh-zshenv.txt | 8 -- .../local-zsh-zshenv.txt | 8 -- .../relay-zsh-zshenv.txt | 8 -- ...erred-startup-line-init.live-shell.test.ts | 76 +++++++++++++++++-- src/main/zsh-deferred-startup-line-init.ts | 12 +-- 5 files changed, 73 insertions(+), 39 deletions(-) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index c73d9a73062f..0c08a5227694 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -276,14 +276,6 @@ __orca_deferred_init() { __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init - local __orca_widget __orca_line_init_bound=0 - for __orca_widget in "${(v)widgets[@]}"; do - if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then - __orca_line_init_bound=1 - break - fi - done - (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 52bbfa7b3272..8b7577e191e2 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -293,14 +293,6 @@ __orca_deferred_init() { __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init - local __orca_widget __orca_line_init_bound=0 - for __orca_widget in "${(v)widgets[@]}"; do - if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then - __orca_line_init_bound=1 - break - fi - done - (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index f52ef39a6767..d17479e64aea 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -243,14 +243,6 @@ __orca_deferred_init() { fi builtin unset _orca_shell_features _orca_histfile (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init - local __orca_widget __orca_line_init_bound=0 - for __orca_widget in "${(v)widgets[@]}"; do - if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then - __orca_line_init_bound=1 - break - fi - done - (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index 48246e10573a..c78a7f2c1cad 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -3,10 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' +import { getDaemonZshWrapperSpec } from './daemon/daemon-zsh-shell-ready-wrapper-spec' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' import { getZshShellReadyWrapperFile } from './providers/local-pty-shell-ready-wrapper-generation' import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { buildZshStartupHook } from './zsh-startup-wrapper-builder' import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' const itWithZsh = hasZsh ? it : it.skip @@ -165,7 +167,6 @@ describe('zsh deferred startup after prompt-hook replacement', () => { ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] : []), 'O_LK=$(command -v orca-dev)', - 'O_IR=${+functions[__orca_deferred_line_init]}', 'O_SR=${+widgets[__orca_saved_line_init]}', ...(scheduleCleanup ? [ @@ -183,7 +184,6 @@ describe('zsh deferred startup after prompt-hook replacement', () => { 'O_LK', 'O_UC', 'O_UN', - 'O_IR', 'O_SR', ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), @@ -204,9 +204,6 @@ describe('zsh deferred startup after prompt-hook replacement', () => { if (!chainedLineInit) { expect(result.values.O_UN).toBe('zle-line-init') } - if (!chainedLineInit && !chainedRedraw && !repeatSource) { - expect(result.values.O_IR).toBe('0') - } expect(result.values.O_SR).toBe('0') if (repeatSource) { expect(result.output).not.toContain('job table full or recursion limit exceeded') @@ -279,3 +276,72 @@ describe('zsh deferred startup after prompt-hook replacement', () => { } ) }) + +describe('zsh plugins that call deferred line-init by name', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + itWithZsh.each(['local', 'daemon'] as const)( + 'keeps the plugin call working across prompts in a %s wrapper', + async (transport) => { + const home = mkdtempSync(join(tmpdir(), 'orca-zsh-named-wrapper-')) + roots.push(home) + const wrapperDir = join(home, 'wrapper') + mkdirSync(wrapperDir) + // Some input plugins copy the bound widget's function name into their own body. + writeFileSync( + join(home, '.zshrc'), + `functions[orca_test_app_mode]=\${widgets[zle-line-init]#user:}' + O_AM=$((\${O_AM:-0}+1))' +zle -N zle-line-init orca_test_app_mode +` + ) + const wrapper = + transport === 'local' + ? getZshShellReadyWrapperFile() + : buildZshStartupHook(getDaemonZshWrapperSpec()) + writeFileSync(join(wrapperDir, '.zshenv'), wrapper) + writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') + const env: Record = { + HOME: home, + USERPROFILE: home, + PATH: '/usr/bin:/bin', + DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', + ZDOTDIR: wrapperDir + } + env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures( + selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: false, + waitsForShellReady: true, + emitsStartupIdentity: false + }) + ) + + const result = await runZshPty({ + env, + commands: [ + 'true', + 'false', + '[[ ${functions[orca_test_app_mode]} == *__orca_deferred_line_init* ]]; O_CALL_REF=$?', + '__orca_deferred_line_init; O_NOOP=$?', + 'O_INIT=${+functions[__orca_deferred_init]}' + ], + report: ['O_AM', 'O_CALL_REF', 'O_NOOP', 'O_INIT'] + }) + + expect(result.output, result.output).not.toContain('command not found') + expect(result.output).toContain(MARKERS.ready) + expect(Number(result.values.O_AM)).toBeGreaterThan(2) + expect(result.values.O_CALL_REF).toBe('0') + expect(result.values.O_NOOP).toBe('0') + expect(result.values.O_INIT).toBe('0') + } + ) +}) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts index 95b9da94df67..403e1ab8f6cc 100644 --- a/src/main/zsh-deferred-startup-line-init.ts +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -44,13 +44,5 @@ export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_s zle -D zle-line-init fi` -// Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. -export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init - local __orca_widget __orca_line_init_bound=0 - for __orca_widget in "\${(v)widgets[@]}"; do - if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then - __orca_line_init_bound=1 - break - fi - done - (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init` +// Why: plugins can copy a call to line-init into their own body; its guard makes it inert after startup. +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init` From 58d1d8d101f7a77b491d4ff45f2255baca657bc6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:52:32 -0700 Subject: [PATCH 06/58] fix(persistence): save editor-only host sessions during quit (#26848) Normalize the incoming host slice and the saved partition before retirement and membership rebasing read their terminal maps. Fixes #25924 Co-authored-by: m4air --- .../loading-store/session-host-partitions.ts | 5 +- ...store-host-partition-before-unload.test.ts | 110 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/main/persistence/loading-store/store-host-partition-before-unload.test.ts diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index a446ae30576a..e102b8c679f3 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -194,8 +194,11 @@ export function setHostWorkspaceSession( hostId: ExecutionHostId, session: WorkspaceSessionState ): void { - const prior = + const persisted = owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + const prior = persisted ? withRequiredWorkspaceSessionMaps(persisted) : undefined + // Host slices omit empty maps; retirement and rebasing need them before inspecting membership. + session = withRequiredWorkspaceSessionMaps(session) // Why here and not at the callers: the before-unload stage path writes the renderer's payload // straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows. session = dropClosedTerminalTabs(preserveRuntimeAuthoredWorkspaceSessionFields(session, prior)) diff --git a/src/main/persistence/loading-store/store-host-partition-before-unload.test.ts b/src/main/persistence/loading-store/store-host-partition-before-unload.test.ts new file mode 100644 index 000000000000..9166da6fcab6 --- /dev/null +++ b/src/main/persistence/loading-store/store-host-partition-before-unload.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + closeTestStores, + createSqliteTestStore, + makeTerminalTab +} from '../../persistence-test-harness' +import { Store } from './store' + +const HOST_ID = 'ssh:target-1' +const WORKTREE = 'repo-1::/srv/app' +const FOLDER_WORKTREE = 'folder:folder-1' +const TAB_ID = 'host-tab' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openStore(): Store { + const directory = mkdtempSync(join(tmpdir(), 'orca-host-before-unload-')) + directories.push(directory) + return createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) +} + +/** The renderer's host split leaves out terminal maps for a host with only editor tabs. */ +function editorOnlySlice(worktreeId = WORKTREE): WorkspaceSessionState { + const slice: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + [worktreeId]: [ + { worktreeId, filePath: '/srv/app/a.ts', relativePath: 'a.ts', language: 'typescript' } + ] + } + } + Reflect.deleteProperty(slice, 'tabsByWorktree') + Reflect.deleteProperty(slice, 'terminalLayoutsByTabId') + return slice +} + +describe('before-unload staging of an editor-only host slice', () => { + it.each([ + [{}, WORKTREE], + [{ 'repo-1': 1 }, WORKTREE], + [{ 'repo-1': 1 }, FOLDER_WORKTREE] + ])('saves the slice when the partition has topology revisions %j (%s)', (revisions, worktree) => { + const store = openStore() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), terminalTopologyRevisionByRepoId: revisions }, + HOST_ID + ) + + store.stageWorkspaceSessionBeforeUnload(editorOnlySlice(worktree), HOST_ID) + + const saved = store.getWorkspaceSession(HOST_ID) + expect(saved.openFilesByWorktree?.[worktree]).toHaveLength(1) + expect(saved.tabsByWorktree).toEqual({}) + expect(saved.terminalLayoutsByTabId).toEqual({}) + }) + + it('accepts an older saved partition that is missing the terminal maps', () => { + const store = openStore() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), terminalTopologyRevisionByRepoId: { 'repo-1': 1 } }, + HOST_ID + ) + const older = store.getWorkspaceSession(HOST_ID) + Reflect.deleteProperty(older, 'tabsByWorktree') + Reflect.deleteProperty(older, 'terminalLayoutsByTabId') + + store.stageWorkspaceSessionBeforeUnload(editorOnlySlice(), HOST_ID) + + expect(store.getWorkspaceSession(HOST_ID).tabsByWorktree).toEqual({}) + }) + + it('keeps fenced live terminal tabs the stale slice leaves out', () => { + const store = openStore() + const tab = makeTerminalTab({ id: TAB_ID, worktreeId: WORKTREE, ptyId: 'live-pty' }) + const layout = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'live-pty' } + } as const + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [tab] }, + terminalLayoutsByTabId: { [TAB_ID]: layout }, + terminalTopologyRevisionByRepoId: { 'repo-1': 1 } + }, + HOST_ID + ) + + store.stageWorkspaceSessionBeforeUnload(editorOnlySlice(), HOST_ID) + + expect(store.getWorkspaceSession(HOST_ID)).toMatchObject({ + tabsByWorktree: { [WORKTREE]: [tab] }, + terminalLayoutsByTabId: { [TAB_ID]: layout } + }) + }) +}) From 7abee44e78465ff78602c1afca05b97a8f73acac Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:53:57 -0400 Subject: [PATCH 07/58] feat(mobile): show SSH host health on the workspace row badge (#26415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): show SSH host health on the workspace row host badge Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name an unhealthy SSH host even in a single-host list The desktop card shows SSH status on any host count, so the phone now reads target summaries whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one target-to-host keying, and a hook-level test pins the reply-to-row wiring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear stale SSH host badges and announce them without the dot A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps, so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox, Disconnected") instead of reading the middle dot. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the single-host health fixtures inline so the test tsc passes Fixtures hoisted into consts widened their health maps to string, which the now-required hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the repo-metadata goldens for SSH host health The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...ettings.repo-metadata-host.platform-1.json | 185 +++---- ...ix-settings.repo-metadata-repo.list-1.json | 61 ++- ...settings.repo-metadata-settings.get-1.json | 150 +++--- ...po-metadata-ssh.listtargetsummaries-1.json | 160 +++--- ...ules-settings-repo-metadata-fulfilled.json | 161 +++--- .../goldens/settings-repo-cache-expiry.json | 148 +++--- .../settings-repo-metadata-fulfilled.json | 61 ++- .../goldens/settings-repo-metadata-icons.json | 107 ++-- ...tings-repo-metadata-refuse-after-data.json | 460 +++++++++--------- .../settings-repo-metadata-refused.json | 61 ++- .../settings-repo-metadata-single-host.json | 237 ++++++++- ...ettings-repo-metadata-transport-error.json | 63 +-- mobile/src/components/WorktreeListRow.test.ts | 25 + mobile/src/components/WorktreeListRow.tsx | 18 +- .../host-screen-reply-schema.test.ts | 14 + .../host-screen/host-screen-reply-schema.ts | 10 +- .../host-show-pinned-in-groups.test.ts | 1 + .../host-ssh-health-badge-wiring.test.ts | 135 +++++ .../host-screen/use-host-display-worktrees.ts | 33 ++ .../src/host-screen/use-host-repo-metadata.ts | 23 +- .../host-screen/use-host-screen-controller.ts | 26 +- .../host-screen/use-host-screen-identity.ts | 2 + .../src/host-screen/use-host-screen-state.ts | 6 + .../workspace-source-reply-schema.test.ts | 2 +- .../tasks/workspace-source-reply-schema.ts | 21 +- .../transport/ssh-connection-status-arms.ts | 20 + .../worktree/host-worktree-refresh.test.ts | 15 + mobile/src/worktree/host-worktree-refresh.ts | 3 + mobile/src/worktree/workspace-list-types.ts | 2 + .../worktree-host-context-labels.test.ts | 53 +- .../worktree/worktree-host-context-labels.ts | 24 +- .../src/worktree/worktree-host-health.test.ts | 46 ++ mobile/src/worktree/worktree-host-health.ts | 39 ++ .../sidebar/sidebar-host-options.ts | 18 +- src/shared/execution-host-health.ts | 47 ++ src/shared/execution-host-registry.ts | 29 +- src/shared/worktree/host-context-labels.ts | 15 +- 37 files changed, 1627 insertions(+), 854 deletions(-) create mode 100644 mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts create mode 100644 mobile/src/host-screen/use-host-display-worktrees.ts create mode 100644 mobile/src/transport/ssh-connection-status-arms.ts create mode 100644 mobile/src/worktree/worktree-host-health.test.ts create mode 100644 mobile/src/worktree/worktree-host-health.ts create mode 100644 src/shared/execution-host-health.ts diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 061899e316a0..27eda6c704c2 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -195,25 +178,6 @@ "startedAt": 0 } }, - "1de50f3b4aac": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": { - "$rpc": "null" - }, - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "1ff13ee8621c": { "name": "repoIconsByName", "ordinal": 4, @@ -417,6 +381,11 @@ } } }, + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, "53dea128b181": { "name": "host.platform#1", "ordinal": 9, @@ -484,10 +453,10 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6134b73f18d0": { "repoColorsByName": [ @@ -618,11 +587,23 @@ } } }, + "9d20695fdb34": { + "name": "hostPlatform", + "ordinal": 15, + "value": { + "$rpc": "null" + } + }, "b428bce85ee7": { "name": "settings.get#1", "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -697,12 +678,25 @@ } } }, - "e9480602a4c6": { - "name": "hostPlatform", - "ordinal": 14, - "value": { + "e841f87c65aa": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": { "$rpc": "null" - } + }, + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -712,10 +706,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -749,8 +756,9 @@ "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -763,14 +771,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -783,14 +792,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -803,14 +813,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -823,14 +834,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -843,14 +855,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -863,14 +876,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -883,14 +897,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -903,14 +918,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -923,14 +939,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -943,14 +960,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -963,14 +981,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } } diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index 4256c025d069..bd38ed25958e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -327,10 +310,15 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6134b73f18d0": { "repoColorsByName": [ @@ -504,6 +492,11 @@ "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -655,10 +648,23 @@ } } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -686,14 +692,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index 4052935a5e6a..423f30621475 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -295,6 +278,11 @@ } } }, + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, "53dea128b181": { "name": "host.platform#1", "ordinal": 9, @@ -327,10 +315,10 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6134b73f18d0": { "repoColorsByName": [ @@ -496,6 +484,13 @@ "startedAt": 0 } }, + "9d20695fdb34": { + "name": "hostPlatform", + "ordinal": 15, + "value": { + "$rpc": "null" + } + }, "ae308dad876a": { "name": "settings.get#1", "ordinal": 8, @@ -567,6 +562,11 @@ } } }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -710,13 +710,6 @@ } } }, - "e9480602a4c6": { - "name": "hostPlatform", - "ordinal": 14, - "value": { - "$rpc": "null" - } - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -725,10 +718,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -762,8 +768,9 @@ "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -776,14 +783,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -796,14 +804,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -816,14 +825,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -836,14 +846,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -856,14 +867,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -876,14 +888,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -896,14 +909,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -916,14 +930,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -936,14 +951,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -956,14 +972,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -976,14 +993,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } } diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index 8ba0eb0ce8dd..eaf4e75fb058 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -72,23 +72,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "08d05f42e4a2": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -427,13 +410,31 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", - "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "523753167e39": { + "hostHealthById": [], + "hostLabelById": [], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] }, - "5ffccd000213": { - "name": "hostLabelById", + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, "value": [] }, @@ -577,6 +578,11 @@ "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -616,8 +622,22 @@ } } }, - "eb68427ac627": { - "hostLabelById": [], + "daecdc0e521b": { + "name": "hostLabelById", + "ordinal": 14, + "value": [] + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], "hostPlatform": "linux", "repoColorsByName": [ ["Local", "#6366f1"], @@ -633,19 +653,6 @@ ["Remote", "repo-2"] ] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" - }, "fa2cb04c517b": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -707,14 +714,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -740,14 +748,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -773,14 +782,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -806,14 +816,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -839,14 +850,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -872,14 +884,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -905,14 +918,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -938,14 +952,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -971,14 +986,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -1004,14 +1020,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } }, @@ -1037,14 +1054,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "eb68427ac627", + "state": "523753167e39", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "5ffccd000213", - "f9193cae5824" + "6063e534d9db", + "daecdc0e521b", + "53226cb82ec3" ] } } diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index f8d65fba25b9..a81b7772b490 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -75,23 +75,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -163,25 +146,6 @@ "startedAt": 0 } }, - "1de50f3b4aac": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": { - "$rpc": "null" - }, - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "1ff13ee8621c": { "name": "repoIconsByName", "ordinal": 4, @@ -279,6 +243,11 @@ } } }, + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, "53dea128b181": { "name": "host.platform#1", "ordinal": 9, @@ -311,11 +280,6 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", - "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] - }, "5b781918b903": { "name": "host.platform#1", "ordinal": 9, @@ -348,6 +312,11 @@ } } }, + "6063e534d9db": { + "name": "hostHealthById", + "ordinal": 13, + "value": [] + }, "6134b73f18d0": { "repoColorsByName": [ ["Local", "#6366f1"], @@ -515,11 +484,23 @@ } } }, + "9d20695fdb34": { + "name": "hostPlatform", + "ordinal": 15, + "value": { + "$rpc": "null" + } + }, "b428bce85ee7": { "name": "settings.get#1", "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -631,12 +612,25 @@ } } }, - "e9480602a4c6": { - "name": "hostPlatform", - "ordinal": 14, - "value": { + "e841f87c65aa": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": { "$rpc": "null" - } + }, + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -646,10 +640,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -677,14 +684,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -710,14 +718,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -743,14 +752,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -776,14 +786,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -828,8 +839,9 @@ "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -842,14 +854,15 @@ "mount": "eb79a9b3682a", "load": "c7f150c7a054" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -863,14 +876,15 @@ "load": "eb79a9b3682a", "disconnect": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } }, @@ -884,14 +898,15 @@ "load": "eb79a9b3682a", "cutover": "eb79a9b3682a" }, - "state": "1de50f3b4aac", + "state": "e841f87c65aa", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "e9480602a4c6" + "6063e534d9db", + "bea13a4a2e87", + "9d20695fdb34" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index e9edf4df1a87..57c042b30b89 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -4,32 +4,6 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "00271b9f4a55": { - "name": "repo.list#2", - "ordinal": 15, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 60000 - } - }, "03c9a2ba60b9": { "name": "host.platform#1", "ordinal": 9, @@ -64,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -157,6 +114,11 @@ "ordinal": 4, "value": [] }, + "2053629911e7": { + "name": "repo.list#2", + "ordinal": 17, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, "2f1b95eac8f1": { "name": "repoColorsByName", "ordinal": 3, @@ -217,10 +179,15 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6134b73f18d0": { "repoColorsByName": [ @@ -297,6 +264,37 @@ "status": "pending", "startedAt": 60000 }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, + "c402c05b8b96": { + "name": "repo.list#2", + "ordinal": 16, + "args": [ + { + "name": "method", + "value": "repo.list" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 60000 + } + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -336,11 +334,6 @@ } } }, - "de81371c7ac8": { - "name": "repo.list#2", - "ordinal": 16, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -357,10 +350,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -388,14 +394,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -409,14 +416,15 @@ "load": "eb79a9b3682a", "cached-query": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -431,14 +439,15 @@ "cached-query": "eb79a9b3682a", "warm-query": "f43afee17848" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -450,14 +459,14 @@ "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9", - "00271b9f4a55" + "c402c05b8b96" ], "payloads": [ "19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396", - "de81371c7ac8" + "2053629911e7" ], "settlements": { "mount": "eb79a9b3682a", @@ -466,14 +475,15 @@ "warm-query": "f43afee17848", "expired-query": "ba8a19e3e2b1" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index aabdbe23adb3..e3be4ab006bc 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -191,10 +174,15 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6134b73f18d0": { "repoColorsByName": [ @@ -267,6 +255,11 @@ "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -314,10 +307,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -345,14 +351,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index 48777a607610..dcd54acfef57 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -4,11 +4,6 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "12ba3f41d4db": { - "name": "hostPlatform", - "ordinal": 14, - "value": "darwin" - }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, @@ -49,15 +44,20 @@ "startedAt": 0 } }, + "25b44bca9e45": { + "name": "hostPlatform", + "ordinal": 15, + "value": "darwin" + }, "36a96ddbd396": { "name": "host.platform#1", "ordinal": 12, "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" }, - "56ab81cb67dc": { - "name": "hostLabelById", + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6338d57f282f": { "repoColorsByName": [ @@ -293,46 +293,13 @@ "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] }, - "e62b46a04ba1": { + "c020f709d835": { + "hostHealthById": [], "hostLabelById": [["ssh:ssh-1", "SSH"]], "hostPlatform": "darwin", "repoColorsByName": [ @@ -376,6 +343,45 @@ ["Avatar", "repo-3"] ] }, + "c752e67787f9": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -444,14 +450,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e62b46a04ba1", + "state": "c020f709d835", "effects": [ "19bf6d2469a6", "ae27a5b525fa", "77cbfd1f087c", "6bc5e5b7a259", - "56ab81cb67dc", - "12ba3f41d4db" + "6063e534d9db", + "bea13a4a2e87", + "25b44bca9e45" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index a458cc7adeb8..53182455b8db 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "0c4296f215a4": { "name": "settings.get#1", "ordinal": 8, @@ -95,6 +78,11 @@ } } }, + "0ef3d910f42e": { + "name": "hostLabelById", + "ordinal": 29, + "value": [["ssh:ssh-1", "SSH"]] + }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, @@ -131,44 +119,10 @@ "ordinal": 4, "value": [] }, - "2a201bc09f96": { - "name": "ssh.listTargetSummaries#2", - "ordinal": 21, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-6", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "2053629911e7": { + "name": "repo.list#2", + "ordinal": 17, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, "2f1b95eac8f1": { "name": "repoColorsByName", @@ -178,27 +132,16 @@ ["Remote", "#f97316"] ] }, - "339b07cb7b7e": { - "name": "repoHostIdByRepoId", - "ordinal": 20, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "35a231ef3ff8": { - "name": "repoIdsByName", - "ordinal": 19, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "36a96ddbd396": { "name": "host.platform#1", "ordinal": 12, "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" }, + "376d3874239c": { + "name": "repoIconsByName", + "ordinal": 19, + "value": [] + }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -246,37 +189,9 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", - "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] - }, - "5b7bcd015aba": { - "name": "repoColorsByName", - "ordinal": 17, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "64986b5eb58e": { + "455855a85ab4": { "name": "settings.get#2", - "ordinal": 22, + "ordinal": 23, "args": [ { "name": "method", @@ -309,28 +224,13 @@ } } }, - "64aa5287977c": { - "name": "hostLabelById", - "ordinal": 27, - "value": [["ssh:ssh-1", "SSH"]] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "673a2bbe62e3": { - "name": "hostPlatform", - "ordinal": 28, - "value": "linux" - }, - "7d3ba1e92188": { - "name": "repo.list#2", - "ordinal": 15, + "4771e3dbbbcf": { + "name": "host.platform#2", + "ordinal": 24, "args": [ { "name": "method", - "value": "repo.list" + "value": "host.platform" }, { "name": "params", @@ -350,27 +250,44 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-5", + "id": "frame-8", "ok": true, "result": { - "repos": [ - { - "connectionId": { - "$rpc": "null" - }, - "displayName": "Local", - "id": "repo-1" - }, - { - "connectionId": "ssh-1", - "displayName": "Remote", - "id": "repo-2" - } - ] + "platform": "linux" } } } }, + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, + "6063e534d9db": { + "name": "hostHealthById", + "ordinal": 13, + "value": [] + }, + "6134b73f18d0": { + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] + }, + "668a7e313975": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 10, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, "7e772c1c111d": { "name": "repoIdsByName", "ordinal": 5, @@ -379,37 +296,11 @@ ["Remote", "repo-2"] ] }, - "8800889a5f18": { + "8c5c812580ad": { "name": "host.platform#2", - "ordinal": 26, + "ordinal": 27, "json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" }, - "8dd3e3e2a847": { - "name": "repo.list#2", - "ordinal": 15, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, "8ef3b348c179": { "name": "repoHostIdByRepoId", "ordinal": 6, @@ -448,20 +339,25 @@ "startedAt": 0 } }, - "983e55643949": { - "name": "settings.get#2", - "ordinal": 25, - "json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "a67416f78b93": { + "name": "hostHealthById", + "ordinal": 28, + "value": [] }, "b428bce85ee7": { "name": "settings.get#1", "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, - "be773224294b": { - "name": "repoIconsByName", - "ordinal": 18, - "value": [] + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, + "c497d2d846c9": { + "name": "ssh.listTargetSummaries#2", + "ordinal": 25, + "json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", @@ -502,15 +398,117 @@ } } }, - "d1f0a64c0651": { + "ceef481be25c": { + "name": "hostPlatform", + "ordinal": 30, + "value": "linux" + }, + "d51254d2537f": { + "name": "settings.get#2", + "ordinal": 26, + "json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "d569b5b2500f": { "name": "ssh.listTargetSummaries#2", - "ordinal": 24, - "json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + "ordinal": 22, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-6", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } + }, + "da7cf9752395": { + "name": "repoColorsByName", + "ordinal": 18, + "value": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ] }, - "de81371c7ac8": { + "df78d2202264": { "name": "repo.list#2", "ordinal": 16, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + "args": [ + { + "name": "method", + "value": "repo.list" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "repos": [ + { + "connectionId": { + "$rpc": "null" + }, + "displayName": "Local", + "id": "repo-1" + }, + { + "connectionId": "ssh-1", + "displayName": "Remote", + "id": "repo-2" + } + ] + } + } + } + }, + "ea2ced0895f3": { + "name": "repoHostIdByRepoId", + "ordinal": 21, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -520,18 +518,39 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "eb885d2ceee5": { + "name": "repoIdsByName", + "ordinal": 20, + "value": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] }, - "fc50a8ac2e64": { - "name": "host.platform#2", - "ordinal": 23, + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] + }, + "fa585aceeec2": { + "name": "repo.list#2", + "ordinal": 16, "args": [ { "name": "method", - "value": "host.platform" + "value": "repo.list" }, { "name": "params", @@ -547,16 +566,8 @@ } ], "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-8", - "ok": true, - "result": { - "platform": "linux" - } - } + "status": "pending", + "startedAt": 0 } } }, @@ -585,14 +596,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -605,14 +617,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -624,28 +637,29 @@ "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9", - "8dd3e3e2a847" + "fa585aceeec2" ], "payloads": [ "19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396", - "de81371c7ac8" + "2053629911e7" ], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "reload": "9270aeb7d9c6" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } }, @@ -657,40 +671,42 @@ "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9", - "7d3ba1e92188", - "2a201bc09f96", - "64986b5eb58e", - "fc50a8ac2e64" + "df78d2202264", + "d569b5b2500f", + "455855a85ab4", + "4771e3dbbbcf" ], "payloads": [ "19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396", - "de81371c7ac8", - "d1f0a64c0651", - "983e55643949", - "8800889a5f18" + "2053629911e7", + "c497d2d846c9", + "d51254d2537f", + "8c5c812580ad" ], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "reload": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824", - "5b7bcd015aba", - "be773224294b", - "35a231ef3ff8", - "339b07cb7b7e", - "64aa5287977c", - "673a2bbe62e3" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3", + "da7cf9752395", + "376d3874239c", + "eb885d2ceee5", + "ea2ced0895f3", + "a67416f78b93", + "0ef3d910f42e", + "ceef481be25c" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 0e850d656898..17e304bc7bcc 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, @@ -186,10 +169,15 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" + }, + "6063e534d9db": { + "name": "hostHealthById", "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "value": [] }, "6134b73f18d0": { "repoColorsByName": [ @@ -262,6 +250,11 @@ "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -309,10 +302,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -340,14 +346,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index f3d00fb95da5..277522dfed59 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -9,6 +9,32 @@ "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, + "1a160fc2d137": { + "name": "host.platform#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, "1ff13ee8621c": { "name": "repoIconsByName", "ordinal": 4, @@ -29,6 +55,11 @@ ["Remote folder", "repo-2"] ] }, + "36a96ddbd396": { + "name": "host.platform#1", + "ordinal": 12, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, "4b6956e45bac": { "name": "repo.list#1", "ordinal": 1, @@ -74,6 +105,110 @@ } } }, + "4bf1ed33de14": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "53dea128b181": { + "name": "host.platform#1", + "ordinal": 9, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "6063e534d9db": { + "name": "hostHealthById", + "ordinal": 13, + "value": [] + }, + "668a7e313975": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 10, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "6a227ad27189": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, "937107a86348": { "name": "repoIdsByName", "ordinal": 5, @@ -82,6 +217,39 @@ ["Remote folder", "repo-2"] ] }, + "96e0c54d86b0": { + "name": "settings.get#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "9d20695fdb34": { + "name": "hostPlatform", + "ordinal": 15, + "value": { + "$rpc": "null" + } + }, "a086cfd03185": { "name": "repoHostIdByRepoId", "ordinal": 6, @@ -90,6 +258,48 @@ ["repo-2", "ssh:ssh-1"] ] }, + "b428bce85ee7": { + "name": "settings.get#1", + "ordinal": 11, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "daecdc0e521b": { + "name": "hostLabelById", + "ordinal": 14, + "value": [] + }, + "dd3998116e8e": { + "name": "settings.get#1", + "ordinal": 8, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -113,15 +323,36 @@ { "id": "single-host-without-label-lookups", "observation": { - "sender": ["4b6956e45bac"], - "payloads": ["19b2979d7fc2"], + "sender": ["4b6956e45bac", "6a227ad27189", "96e0c54d86b0", "1a160fc2d137"], + "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], "settlements": { "mount": "eb79a9b3682a", - "load": "eb79a9b3682a" + "load": "9270aeb7d9c6" }, "state": "2bd489a9fa29", "effects": ["fc0785cc0b08", "1ff13ee8621c", "937107a86348", "a086cfd03185"] } + }, + { + "id": "cleanup", + "observation": { + "sender": ["4b6956e45bac", "4bf1ed33de14", "dd3998116e8e", "53dea128b181"], + "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "settlements": { + "mount": "eb79a9b3682a", + "load": "eb79a9b3682a" + }, + "state": "2bd489a9fa29", + "effects": [ + "fc0785cc0b08", + "1ff13ee8621c", + "937107a86348", + "a086cfd03185", + "6063e534d9db", + "daecdc0e521b", + "9d20695fdb34" + ] + } } ] } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index 3dec40ec45f6..965d2f813203 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -38,23 +38,6 @@ } } }, - "071880b671a1": { - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, @@ -151,10 +134,10 @@ } } }, - "56ab81cb67dc": { - "name": "hostLabelById", - "ordinal": 13, - "value": [["ssh:ssh-1", "SSH"]] + "53226cb82ec3": { + "name": "hostPlatform", + "ordinal": 15, + "value": "linux" }, "6021c58b2fd2": { "name": "settings.get#1", @@ -188,6 +171,11 @@ } } }, + "6063e534d9db": { + "name": "hostHealthById", + "ordinal": 13, + "value": [] + }, "6134b73f18d0": { "repoColorsByName": [ ["Local", "#6366f1"], @@ -259,6 +247,11 @@ "ordinal": 11, "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, + "bea13a4a2e87": { + "name": "hostLabelById", + "ordinal": 14, + "value": [["ssh:ssh-1", "SSH"]] + }, "c752e67787f9": { "name": "ssh.listTargetSummaries#1", "ordinal": 7, @@ -306,10 +299,23 @@ "$rpc": "undefined" } }, - "f9193cae5824": { - "name": "hostPlatform", - "ordinal": 14, - "value": "linux" + "f4667dda0187": { + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoColorsByName": [ + ["Local", "#6366f1"], + ["Remote", "#f97316"] + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ], + "repoIconsByName": [], + "repoIdsByName": [ + ["Local", "repo-1"], + ["Remote", "repo-2"] + ] } }, "recording": { @@ -337,14 +343,15 @@ "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "071880b671a1", + "state": "f4667dda0187", "effects": [ "2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179", - "56ab81cb67dc", - "f9193cae5824" + "6063e534d9db", + "bea13a4a2e87", + "53226cb82ec3" ] } } diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index 7b6d222d29ec..722202d004b1 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -244,7 +244,32 @@ describe('memoized worktree rows', () => { ) }) expect(textNodes()).toContain('openclaw') + expect(textNodes().some((text) => text.includes('Disconnected'))).toBe(false) expect(renderer!.root.findAllByType('Server' as never)).toHaveLength(1) + + await act(async () => + renderer!.update( + createElement(ListRowHarness, { + item: { + ...baseItem, + hostId: 'ssh:ssh-1', + hostContextLabel: 'openclaw', + hostContextHealthLabel: 'Disconnected' + }, + now: 2_000 + }) + ) + ) + // Separate nodes: the name truncates while the health word stays whole. + expect(textNodes()).toEqual(expect.arrayContaining(['openclaw', '· Disconnected'])) + const healthText = renderer!.root + .findAllByType('Text' as never) + .find((node) => node.props.children === '· Disconnected') + expect([healthText?.props.style].flat()).toContainEqual({ flexShrink: 0 }) + const announcedBadges = renderer!.root.findAll( + (node) => node.props.accessibilityLabel === 'openclaw, Disconnected' + ) + expect(announcedBadges).toHaveLength(1) expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0) await act(async () => diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 98684eab449c..0da389900120 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -36,6 +36,8 @@ export type WorktreeListRowItem = { hostContextLabel?: string /** Resolved host for the display label; present when legacy rows omit hostId. */ hostContextHostId?: ExecutionHostId + /** The host's health word, e.g. "Disconnected"; absent while the host is healthy. */ + hostContextHealthLabel?: string repo: string branch: string displayName: string @@ -165,7 +167,12 @@ function WorktreeListRowComponent({ )} {item.hostContextLabel ? ( - + {/* Rows from hosts that predate hostId stamping are local: a remote row always carries one. */} {(parseExecutionHostId(item.hostContextHostId ?? item.hostId)?.kind ?? 'local') === 'local' ? ( @@ -176,6 +183,12 @@ function WorktreeListRowComponent({ {item.hostContextLabel} + {/* Why separate Text: the name truncates first so the health word stays readable. */} + {item.hostContextHealthLabel ? ( + + {`· ${item.hostContextHealthLabel}`} + + ) : null} ) : null} {/* Repo glyph+name only when not already grouped under this repo; @@ -341,6 +354,9 @@ const styles = StyleSheet.create({ hostBadgeText: { flexShrink: 1 }, + hostHealthText: { + flexShrink: 0 + }, lineageToggle: { alignSelf: 'flex-start', flexDirection: 'row', diff --git a/mobile/src/host-screen/host-screen-reply-schema.test.ts b/mobile/src/host-screen/host-screen-reply-schema.test.ts index 0f36f86e3d42..2d1d152a9f0a 100644 --- a/mobile/src/host-screen/host-screen-reply-schema.test.ts +++ b/mobile/src/host-screen/host-screen-reply-schema.test.ts @@ -83,6 +83,20 @@ describe('host screen reply schemas', () => { ).toEqual([{ id: 't', label: 'T' }]) }) + it('keeps SSH lifecycle fields raw and drops a malformed one without losing the target', () => { + expect( + hostSshTargetSummariesSchema.parse({ + targets: [ + { id: 't', label: 'T', connected: false, connectionStatus: 'hibernating' }, + { id: 'u', label: 'U', connected: 'no', connectionStatus: 7 } + ] + }) + ).toEqual([ + { id: 't', label: 'T', connected: false, connectionStatus: 'hibernating' }, + { id: 'u', label: 'U' } + ]) + }) + it('answers the empty target list for a reply that is no object at all', () => { // Why: the label write runs before the platform write in the same sequence, so a throw here // would take the platform down with it. readSshTargets answered [] for every one of these. diff --git a/mobile/src/host-screen/host-screen-reply-schema.ts b/mobile/src/host-screen/host-screen-reply-schema.ts index ddc72e299663..428699195613 100644 --- a/mobile/src/host-screen/host-screen-reply-schema.ts +++ b/mobile/src/host-screen/host-screen-reply-schema.ts @@ -93,7 +93,15 @@ export const hostSshTargetSummariesSchema = z .looseObject({ targets: salvagedOptional( 'targets', - salvagingArray(z.looseObject({ id: z.string(), label: z.string() })) + salvagingArray( + z.looseObject({ + id: z.string(), + label: z.string(), + // Read raw: an arm a newer desktop adds must leave the badge as it is, not read as a verdict. + connected: salvagedOptional('connected', z.boolean()), + connectionStatus: salvagedOptional('connectionStatus', z.string()) + }) + ) ) }) .transform((reply) => reply.targets ?? []) diff --git a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts index 29e6c8db826e..376b8dbebc27 100644 --- a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts +++ b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts @@ -48,6 +48,7 @@ function screenState(clientRef: ClientRef, applied: boolean[]) { 'setCatalogError', 'setError', 'setHostLabelById', + 'setHostHealthById', 'setHostName', 'setHostPlatform', 'setHostStoredDescriptor', diff --git a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts new file mode 100644 index 000000000000..ffd7e73f63a2 --- /dev/null +++ b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts @@ -0,0 +1,135 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../cache/worktree-cache', () => ({ + getCachedWorktrees: () => null, + setCachedWorktrees: () => {} +})) +vi.mock('../cache/repo-cache', () => ({ setCachedRepos: () => {} })) + +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import type { RpcResponse } from '../transport/types' +import type { Worktree } from '../worktree/workspace-list-types' +import { useHostDisplayWorktrees } from './use-host-display-worktrees' +import { useHostRepoMetadata } from './use-host-repo-metadata' +import { useHostScreenState } from './use-host-screen-state' + +function reply(result: unknown): RpcResponse { + return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime' } } +} + +function row(worktreeId: string, repoId: string): Worktree { + return { + workspaceKind: 'git', + worktreeId, + repoId, + repo: repoId, + branch: 'main', + displayName: worktreeId, + path: `/home/me/${worktreeId}`, + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null + } +} + +// Drives the screen's own state, metadata refresh and row projection against one desktop reply set. +async function displayedRows(args: { + repos: { id: string; displayName: string; connectionId?: string }[] + rows: Worktree[] + sshTarget: Record + /** The catalog a second refresh answers, once the first has applied. */ + thenRepos?: { id: string; displayName: string; connectionId?: string }[] +}): Promise { + let repos = args.repos + const client = new FakeSession('connected') + client.sendRequest.mockImplementation(async (method: string) => { + switch (method) { + case 'repo.list': + return reply({ repos }) + case 'ssh.listTargetSummaries': + return reply({ targets: [{ id: 'devbox', label: 'devbox', ...args.sshTarget }] }) + case 'host.platform': + return reply({ platform: 'darwin' }) + default: + return reply({}) + } + }) + const seen: { rows: Worktree[]; refresh?: (options: { force: boolean }) => Promise } = { + rows: [] + } + let setWorktrees: ((rows: Worktree[]) => void) | undefined + function Probe(): null { + const state = useHostScreenState('host-1', undefined) + state.clientRef.current = client + setWorktrees = state.setWorktrees + seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state }) + seen.rows = useHostDisplayWorktrees('connected', state) + return null + } + await act(async () => { + create(createElement(Probe)) + }) + await act(async () => { + setWorktrees?.(args.rows) + await seen.refresh?.({ force: true }) + }) + if (args.thenRepos) { + repos = args.thenRepos + await act(async () => { + await seen.refresh?.({ force: true }) + }) + } + return seen.rows +} + +describe('SSH host health on the host list rows', () => { + it('names an unreachable SSH host with its health even when every row is on it', async () => { + const rows = await displayedRows({ + repos: [{ id: 'repo-ssh', displayName: 'api', connectionId: 'devbox' }], + rows: [row('a', 'repo-ssh'), row('b', 'repo-ssh')], + sshTarget: { connected: false, connectionStatus: 'disconnected' } + }) + expect(rows.map((r) => [r.hostContextLabel, r.hostContextHealthLabel])).toEqual([ + ['devbox', 'Disconnected'], + ['devbox', 'Disconnected'] + ]) + }) + + it('carries the health beside the label in a mixed-host list', async () => { + const rows = await displayedRows({ + repos: [ + { id: 'repo-local', displayName: 'web' }, + { id: 'repo-ssh', displayName: 'api', connectionId: 'devbox' } + ], + rows: [row('a', 'repo-local'), row('b', 'repo-ssh')], + sshTarget: { connected: false, connectionStatus: 'reconnecting' } + }) + expect(rows.map((r) => r.hostContextHealthLabel)).toEqual([undefined, 'Connecting']) + }) + + it('drops the badge once the SSH host leaves the catalog', async () => { + const rows = await displayedRows({ + repos: [{ id: 'repo-ssh', displayName: 'api', connectionId: 'devbox' }], + rows: [{ ...row('a', 'repo-ssh'), hostId: 'ssh:devbox' }], + sshTarget: { connected: false, connectionStatus: 'disconnected' }, + thenRepos: [{ id: 'repo-local', displayName: 'web' }] + }) + expect(rows[0].hostContextLabel).toBeUndefined() + expect(rows[0].hostContextHealthLabel).toBeUndefined() + }) + + it('keeps a healthy single-host list bare', async () => { + const rows = await displayedRows({ + repos: [{ id: 'repo-ssh', displayName: 'api', connectionId: 'devbox' }], + rows: [row('a', 'repo-ssh')], + sshTarget: { connected: true, connectionStatus: 'connected' } + }) + expect(rows[0].hostContextLabel).toBeUndefined() + expect(rows[0].hostContextHealthLabel).toBeUndefined() + }) +}) diff --git a/mobile/src/host-screen/use-host-display-worktrees.ts b/mobile/src/host-screen/use-host-display-worktrees.ts new file mode 100644 index 000000000000..3a24e56356d6 --- /dev/null +++ b/mobile/src/host-screen/use-host-display-worktrees.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react' +import type { ConnectionState } from '../transport/types' +import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels' +import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity' +import type { HostScreenState } from './use-host-screen-state' + +/** The rows the host list renders: the live or last-known catalog with display state and host badges. */ +export function useHostDisplayWorktrees(connState: ConnectionState, state: HostScreenState) { + return useMemo(() => { + // Why: live `worktrees` is authoritative only while connected; under the amber + // mount default, connecting/handshaking must keep the pre-reconnect list too. + const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees + return applyWorktreeHostContextLabels( + applyWorktreeRowDisplayState(base, state.sleptIds, state.optimisticActiveWorktreeIdentity), + { + repoHostIdByRepoId: state.repoHostIdByRepoId, + hostLabelById: state.hostLabelById, + hostPlatform: state.hostPlatform, + hostHealthById: state.hostHealthById + } + ) + }, [ + connState, + state.worktrees, + state.lastKnownWorktrees, + state.sleptIds, + state.optimisticActiveWorktreeIdentity, + state.repoHostIdByRepoId, + state.hostLabelById, + state.hostPlatform, + state.hostHealthById + ]) +} diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 0987aed14b04..892e90ff5122 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -1,6 +1,6 @@ import { optionalSettingsRead } from '../transport/settings-read-operations' import { useCallback } from 'react' -import { getRepoExecutionHostId } from '../../../src/shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host' import { setCachedRepos } from '../cache/repo-cache' import type { RpcAcceptedResult } from '../transport/rpc-accepted-result' import type { RpcClient } from '../transport/rpc-client' @@ -10,6 +10,7 @@ import { buildHostLabelById, buildRepoHostIdByRepoId } from '../worktree/worktree-host-context-labels' +import { buildSshHostHealthById } from '../worktree/worktree-host-health' import { hostPlatformRead, hostRepoCatalogRead, @@ -58,6 +59,7 @@ export function useHostRepoMetadata(args: { fetchRepoMetadataPendingRef, repoMetadataFetchedAtRef, setHostLabelById, + setHostHealthById, setHostPlatform, setRepoColorsByName, setRepoHostIdByRepoId, @@ -116,11 +118,12 @@ export function useHostRepoMetadata(args: { ) setRepoIdsByName(new Map(catalog.map((repo) => [repo.displayName, repo.id]))) setRepoHostIdByRepoId(buildRepoHostIdByRepoId(catalog)) - // Why: rows only name their host when the list spans hosts, so a single-host - // catalog never pays for the label lookups. Counted over repos, not the id-keyed - // map: one repo id registered on two hosts is two hosts. + // Why: rows name their host when the list spans hosts or an SSH host may be unhealthy, + // so a local-only catalog never pays for the label lookups. Counted over repos, not the + // id-keyed map: one repo id registered on two hosts is two hosts. const hostIds = new Set(catalog.map((repo) => getRepoExecutionHostId(repo))) - if (hostIds.size > 1) { + const hasSshHost = [...hostIds].some((id) => parseExecutionHostId(id)?.kind === 'ssh') + if (hostIds.size > 1 || hasSshHost) { const [sshTargets, hostSettings, hostPlatform] = await Promise.all([ settledMetadataReply(() => hostSshTargetSummariesRead.request(requestClient)), optionalSettingsRead.request(requestClient).catch(() => null), @@ -132,16 +135,22 @@ export function useHostRepoMetadata(args: { const hostSettingsResult = hostSettings ? optionalSettingsRead.interpret(hostSettings) : null + const sshTargetSummaries = + acceptedMetadata(sshTargets, hostSshTargetSummariesRead.interpret) ?? [] + setHostHealthById(buildSshHostHealthById(sshTargetSummaries)) setHostLabelById( buildHostLabelById({ - sshTargets: - acceptedMetadata(sshTargets, hostSshTargetSummariesRead.interpret) ?? [], + sshTargets: sshTargetSummaries, hostSettingOverrides: readHostSettingOverrides( hostSettingsResult?.accepted ? hostSettingsResult.value : undefined ) }) ) setHostPlatform(acceptedMetadata(hostPlatform, hostPlatformRead.interpret) ?? null) + } else { + // Why: a host that left the catalog must not keep badging rows that still name it. + setHostHealthById(new Map()) + setHostLabelById(new Map()) } } while (fetchRepoMetadataPendingRef.current.has(requestClient)) } catch { diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 34efd5e71acb..8751113f0374 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -14,9 +14,8 @@ import { useReconnectAttempt, useRelayRecoveryStatus } from '../transport/client-context-connection-metrics' -import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity' -import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels' import { useWorkspaceSections } from '../worktree/use-workspace-sections' +import { useHostDisplayWorktrees } from './use-host-display-worktrees' import { useHostRepoMetadata } from './use-host-repo-metadata' import { useHostScreenIdentity } from './use-host-screen-identity' import { useHostScreenState } from './use-host-screen-state' @@ -102,28 +101,7 @@ export function useHostScreenController({ } const showNewWorktree = resolvedRouteActionState.showNewWorktree - const displayWorktrees = useMemo(() => { - // Why: live `worktrees` is authoritative only while connected; under the amber - // mount default, connecting/handshaking must keep the pre-reconnect list too. - const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees - return applyWorktreeHostContextLabels( - applyWorktreeRowDisplayState(base, state.sleptIds, state.optimisticActiveWorktreeIdentity), - { - repoHostIdByRepoId: state.repoHostIdByRepoId, - hostLabelById: state.hostLabelById, - hostPlatform: state.hostPlatform - } - ) - }, [ - connState, - state.worktrees, - state.lastKnownWorktrees, - state.sleptIds, - state.optimisticActiveWorktreeIdentity, - state.repoHostIdByRepoId, - state.hostLabelById, - state.hostPlatform - ]) + const displayWorktrees = useHostDisplayWorktrees(connState, state) const sectionsResult = useWorkspaceSections({ displayWorktrees, sortMode: state.sortMode, diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts index 25cc71eeade4..6f675b8457fb 100644 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ b/mobile/src/host-screen/use-host-screen-identity.ts @@ -18,6 +18,7 @@ export function useHostScreenIdentity(args: { setCatalogError, setError, setHostLabelById, + setHostHealthById, setHostName, setHostPlatform, setHostStoredDescriptor, @@ -62,6 +63,7 @@ export function useHostScreenIdentity(args: { setRepoIconsByName(new Map()) setRepoHostIdByRepoId(new Map()) setHostLabelById(new Map()) + setHostHealthById(new Map()) setHostPlatform(null) setShowPinnedInGroups(false) repoMetadataFetchedAtRef.current = 0 diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index 6625119f2053..6cdc4100bc75 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -1,5 +1,6 @@ import { useRef, useState } from 'react' import type { ExecutionHostId } from '../../../src/shared/execution-host' +import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types' import { getCachedWorktrees } from '../cache/worktree-cache' import { createInitialHostRouteActionState } from '../host-route-action-state' @@ -71,6 +72,9 @@ export function useHostScreenState(hostId: string | undefined, action: string | new Map() ) const [hostLabelById, setHostLabelById] = useState>(new Map()) + const [hostHealthById, setHostHealthById] = useState>( + new Map() + ) const [hostPlatform, setHostPlatform] = useState(null) const [showSortPicker, setShowSortPicker] = useState(false) const [showGroupPicker, setShowGroupPicker] = useState(false) @@ -113,6 +117,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | filters, groupMode, hostLabelById, + hostHealthById, hostName, hostPlatform, hostStoredDescriptor, @@ -138,6 +143,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | setFilters, setGroupMode, setHostLabelById, + setHostHealthById, setHostName, setHostPlatform, setHostStoredDescriptor, diff --git a/mobile/src/tasks/workspace-source-reply-schema.test.ts b/mobile/src/tasks/workspace-source-reply-schema.test.ts index 74ccecfbd041..a8ae643afeb1 100644 --- a/mobile/src/tasks/workspace-source-reply-schema.test.ts +++ b/mobile/src/tasks/workspace-source-reply-schema.test.ts @@ -7,9 +7,9 @@ import { repoSetupHooksSchema, repoSparsePresetListSchema, repoSparsePresetSaveSchema, - SSH_CONNECTION_STATUS, sshConnectionStateSchema } from './workspace-source-reply-schema' +import { SSH_CONNECTION_STATUS } from '../transport/ssh-connection-status-arms' // Pins the SSH status degrade, the error tri-state beside it, and the preset requirement. diff --git a/mobile/src/tasks/workspace-source-reply-schema.ts b/mobile/src/tasks/workspace-source-reply-schema.ts index 54b0174f65f7..f383e30f92da 100644 --- a/mobile/src/tasks/workspace-source-reply-schema.ts +++ b/mobile/src/tasks/workspace-source-reply-schema.ts @@ -1,11 +1,6 @@ import { z } from 'zod' -import type { SshConnectionStatus } from '../../../src/shared/ssh-types' -import { - hostUnionArms, - openEnum, - salvagedOptional, - salvagingArray -} from '../../../src/shared/zod-salvage' +import { openEnum, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' +import { SSH_CONNECTION_STATUS } from '../transport/ssh-connection-status-arms' // The repo and SSH reads the workspace-create drawer runs. Checked against // src/main/runtime/rpc/methods/ssh.ts:30-46 (getPublicSshState, SshConnectionState in @@ -13,18 +8,6 @@ import { // answer a bare `string[]`), and repo.ts:87-103/:184-192 (the sparse preset envelopes, the ref // search and the orca.yaml hooks). -// Pinned to the host's own union through hostUnionArms: an arm added or dropped host-side fails tsc. -export const SSH_CONNECTION_STATUS = hostUnionArms({ - disconnected: true, - connecting: true, - 'auth-failed': true, - 'deploying-relay': true, - connected: true, - reconnecting: true, - 'reconnection-failed': true, - error: true -}) - const sourceText = (name: string) => salvagedOptional(name, z.string()) /** diff --git a/mobile/src/transport/ssh-connection-status-arms.ts b/mobile/src/transport/ssh-connection-status-arms.ts new file mode 100644 index 000000000000..519c603e8b79 --- /dev/null +++ b/mobile/src/transport/ssh-connection-status-arms.ts @@ -0,0 +1,20 @@ +import type { SshConnectionStatus } from '../../../src/shared/ssh-types' +import { hostUnionArms } from '../../../src/shared/zod-salvage' + +// Pinned to the host's own union through hostUnionArms: an arm added or dropped host-side fails tsc. +export const SSH_CONNECTION_STATUS = hostUnionArms({ + disconnected: true, + connecting: true, + 'auth-failed': true, + 'deploying-relay': true, + connected: true, + reconnecting: true, + 'reconnection-failed': true, + error: true +}) + +const KNOWN_SSH_CONNECTION_STATUSES: ReadonlySet = new Set(SSH_CONNECTION_STATUS) + +export function isSshConnectionStatus(status: string): status is SshConnectionStatus { + return KNOWN_SSH_CONNECTION_STATUSES.has(status) +} diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts index d0b8cf01daba..669e312e52c2 100644 --- a/mobile/src/worktree/host-worktree-refresh.test.ts +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -113,6 +113,21 @@ describe('startHostWorktreeRefresh', () => { expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true }) }) + it('re-reads repo metadata, which carries SSH host health, on sshStateChanged', () => { + start() + fetchWorktrees.mockClear() + fetchRepoMetadata.mockClear() + + eventListener?.({ + type: 'sshStateChanged', + targetId: 'ssh-1', + state: { targetId: 'ssh-1', status: 'reconnecting' } + }) + + expect(fetchWorktrees).not.toHaveBeenCalled() + expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true }) + }) + it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => { start() fetchWorktrees.mockClear() diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index 525fc335b8f0..e090d835f578 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -74,6 +74,9 @@ export function startHostWorktreeRefresh({ void fetchRepoMetadata({ force: true, queueIfInFlight: true }) } else if (event.type === 'worktreesChanged') { void fetchWorktrees() + } else if (event.type === 'sshStateChanged') { + // Why: host badges carry SSH health, read with the labels from ssh.listTargetSummaries. + void fetchRepoMetadata({ force: true, queueIfInFlight: true }) } } ) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 255e429e892d..556bd1a7dc15 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -13,6 +13,8 @@ export type Worktree = { hostContextLabel?: string /** Resolved host for the display label; present when legacy rows omit hostId. */ hostContextHostId?: ExecutionHostId + /** Display-only; the host's health word beside its label, absent while the host is healthy. */ + hostContextHealthLabel?: string repo: string branch: string displayName: string diff --git a/mobile/src/worktree/worktree-host-context-labels.test.ts b/mobile/src/worktree/worktree-host-context-labels.test.ts index a921ceebb905..0faf9b947fb4 100644 --- a/mobile/src/worktree/worktree-host-context-labels.test.ts +++ b/mobile/src/worktree/worktree-host-context-labels.test.ts @@ -86,7 +86,8 @@ describe('getWorktreeHostContextLabels', () => { const sources = { repoHostIdByRepoId: new Map(), hostLabelById: new Map([[sshHostId, 'openclaw']]), - hostPlatform: 'darwin' as const + hostPlatform: 'darwin' as const, + hostHealthById: new Map() } it('returns nothing for a single-host list', () => { @@ -95,6 +96,28 @@ describe('getWorktreeHostContextLabels', () => { expect(applyWorktreeHostContextLabels(rows, sources)).toBe(rows) }) + it('names an unhealthy host even in a single-host list, and only that host', () => { + const rows = [worktree({ hostId: sshHostId, worktreeId: 'a' })] + const unhealthy = applyWorktreeHostContextLabels(rows, { + ...sources, + hostHealthById: new Map([[sshHostId, 'connecting']]) + }) + expect(unhealthy.map((row) => [row.hostContextLabel, row.hostContextHealthLabel])).toEqual([ + ['openclaw', 'Connecting'] + ]) + expect( + applyWorktreeHostContextLabels(rows, { + ...sources, + hostHealthById: new Map([[sshHostId, 'available']]) + }) + ).toBe(rows) + const otherHostUnhealthy = applyWorktreeHostContextLabels(rows, { + ...sources, + hostHealthById: new Map([['ssh:x', 'error']]) + }) + expect(otherHostUnhealthy[0].hostContextLabel).toBeUndefined() + }) + it('names every row by host once the list spans hosts', () => { const rows = [ worktree({ hostId: 'local', worktreeId: 'a' }), @@ -111,6 +134,34 @@ describe('getWorktreeHostContextLabels', () => { ]) }) + it('adds the health word only for hosts the desktop reports as unhealthy', () => { + const rows = [ + worktree({ hostId: 'local', worktreeId: 'a' }), + worktree({ hostId: sshHostId, worktreeId: 'b' }), + worktree({ hostId: 'ssh:healthy', worktreeId: 'c' }), + worktree({ hostId: 'runtime:env-1', worktreeId: 'd' }), + worktree({ repoId: 'repo-ssh', worktreeId: 'e' }) + ] + const labeled = applyWorktreeHostContextLabels(rows, { + ...sources, + repoHostIdByRepoId: buildRepoHostIdByRepoId([ + { id: 'repo-ssh', connectionId: 'ssh-1785104650217-eduhep' } + ]), + hostHealthById: new Map([ + ['local', 'local'], + [sshHostId, 'disconnected'], + ['ssh:healthy', 'available'] + ]) + }) + expect(labeled.map((row) => row.hostContextHealthLabel)).toEqual([ + undefined, + 'Disconnected', + undefined, + undefined, + 'Disconnected' + ]) + }) + it('names the local host from the paired host platform, not the phone', () => { const rows = [ worktree({ hostId: 'local', worktreeId: 'a' }), diff --git a/mobile/src/worktree/worktree-host-context-labels.ts b/mobile/src/worktree/worktree-host-context-labels.ts index 406e6d8ce034..f8f1b877afe6 100644 --- a/mobile/src/worktree/worktree-host-context-labels.ts +++ b/mobile/src/worktree/worktree-host-context-labels.ts @@ -4,13 +4,18 @@ import { normalizeExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' -import { getMixedHostContextLabels as getSharedMixedHostContextLabels } from '../../../src/shared/worktree/host-context-labels' +import { + getHostContextLabel, + getMixedHostContextLabels as getSharedMixedHostContextLabels +} from '../../../src/shared/worktree/host-context-labels' import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' export { buildHostLabelById, getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' +import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health' import type { Worktree } from './workspace-list-types' +import { getHostHealthBadgeLabel } from './worktree-host-health' export type HostLabelSources = { /** Host id per repo id from repo.list; rows from hosts that predate `hostId` fall back to it. */ @@ -19,6 +24,8 @@ export type HostLabelSources = { hostLabelById: ReadonlyMap /** The paired host's own platform; the phone's platform must never name the desktop. */ hostPlatform: NodeJS.Platform | null + /** Health per host the desktop reports one for; an absent host keeps the bare label. */ + hostHealthById: ReadonlyMap } export function buildRepoHostIdByRepoId( @@ -72,20 +79,27 @@ export function applyWorktreeHostContextLabels( sources: HostLabelSources ): Worktree[] { const labels = getWorktreeHostContextLabels(worktrees, sources) - if (!labels) { + // Why: the desktop card shows SSH status on any host count, so an unhealthy host is named even alone. + const anyUnhealthy = [...sources.hostHealthById.values()].some(getHostHealthBadgeLabel) + if (!labels && !anyUnhealthy) { return worktrees } return worktrees.map((worktree) => { - const hostContextLabel = labels.get( - getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId) + const hostContextHostId = resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId) + const hostContextHealthLabel = getHostHealthBadgeLabel( + sources.hostHealthById.get(hostContextHostId) ) + const hostContextLabel = labels + ? labels.get(getResolvedWorktreeRowIdentity(worktree, sources.repoHostIdByRepoId)) + : hostContextHealthLabel && getHostContextLabel(hostContextHostId, sources) if (!hostContextLabel) { return worktree } return { ...worktree, hostContextLabel, - hostContextHostId: resolveWorktreeHostId(worktree, sources.repoHostIdByRepoId) + hostContextHostId, + ...(hostContextHealthLabel ? { hostContextHealthLabel } : {}) } }) } diff --git a/mobile/src/worktree/worktree-host-health.test.ts b/mobile/src/worktree/worktree-host-health.test.ts new file mode 100644 index 000000000000..312cd8718921 --- /dev/null +++ b/mobile/src/worktree/worktree-host-health.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { buildSshHostHealthById, getHostHealthBadgeLabel } from './worktree-host-health' + +describe('buildSshHostHealthById', () => { + it('derives each SSH host health from its lifecycle, keyed by canonical host id', () => { + const health = buildSshHostHealthById([ + { id: 'up', connected: true, connectionStatus: 'connected' }, + { id: 'relay', connected: false, connectionStatus: 'deploying-relay' }, + { id: 'retry', connected: false, connectionStatus: 'reconnecting' }, + { id: 'down', connected: false, connectionStatus: 'disconnected' }, + { id: 'auth', connected: false, connectionStatus: 'auth-failed' }, + { id: 'never', connected: false }, + { id: 'ssh:canonical', connected: false, connectionStatus: 'connecting' } + ]) + expect(Object.fromEntries(health)).toEqual({ + 'ssh:up': 'available', + 'ssh:relay': 'connecting', + 'ssh:retry': 'connecting', + 'ssh:down': 'disconnected', + 'ssh:auth': 'error', + // Same verdict as the desktop sidebar for a target with no lifecycle state yet. + 'ssh:never': 'disconnected', + 'ssh:canonical': 'connecting' + }) + }) + + it('reports nothing for a desktop that predates lifecycle fields or a status it cannot read', () => { + const health = buildSshHostHealthById([ + { id: 'old-desktop' }, + { id: 'future', connected: false, connectionStatus: 'hibernating' } + ]) + expect(health.size).toBe(0) + }) +}) + +describe('getHostHealthBadgeLabel', () => { + it('uses the desktop sidebar words for unhealthy hosts only', () => { + expect(getHostHealthBadgeLabel('disconnected')).toBe('Disconnected') + expect(getHostHealthBadgeLabel('connecting')).toBe('Connecting') + expect(getHostHealthBadgeLabel('error')).toBe('Needs attention') + expect(getHostHealthBadgeLabel('blocked')).toBe('Update needed') + expect(getHostHealthBadgeLabel('available')).toBeUndefined() + expect(getHostHealthBadgeLabel('local')).toBeUndefined() + expect(getHostHealthBadgeLabel(undefined)).toBeUndefined() + }) +}) diff --git a/mobile/src/worktree/worktree-host-health.ts b/mobile/src/worktree/worktree-host-health.ts new file mode 100644 index 000000000000..9618ca586788 --- /dev/null +++ b/mobile/src/worktree/worktree-host-health.ts @@ -0,0 +1,39 @@ +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { + getExecutionHostHealthLabel, + getSshConnectionHealth, + type ExecutionHostHealth +} from '../../../src/shared/execution-host-health' +import { sshTargetSummaryHostId } from '../../../src/shared/worktree/host-context-labels' +import { isSshConnectionStatus } from '../transport/ssh-connection-status-arms' + +/** + * Health per SSH host, derived exactly as the desktop sidebar derives it. A desktop that predates + * `connected` reports no lifecycle, and an unknown status is no verdict: both leave the host out. + */ +export function buildSshHostHealthById( + sshTargets: readonly { id: string; connected?: boolean; connectionStatus?: string }[] +): Map { + const health = new Map() + for (const target of sshTargets) { + const status = target.connectionStatus + if ( + target.connected === undefined || + (status !== undefined && !isSshConnectionStatus(status)) + ) { + continue + } + health.set(sshTargetSummaryHostId(target.id), getSshConnectionHealth(status)) + } + return health +} + +/** The word a row's host badge adds; a healthy or local host keeps the bare name. */ +export function getHostHealthBadgeLabel( + health: ExecutionHostHealth | undefined +): string | undefined { + if (health === undefined || health === 'local' || health === 'available') { + return undefined + } + return getExecutionHostHealthLabel(health) +} diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index ffd80395e51d..deb4977a253a 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -11,6 +11,7 @@ import { buildExecutionHostRegistry, type ExecutionHostHealth } from '../../../../shared/execution-host-registry' +import { getExecutionHostHealthLabel } from '../../../../shared/execution-host-health' import { expandEquivalentExecutionHostIds, pickerExecutionHosts @@ -162,22 +163,7 @@ export function getCheckedHostRows( } export function getSidebarHostHealthLabel(health: SidebarHostScopeOption['health']): string { - switch (health) { - case 'local': - return 'Local' - case 'available': - return 'Connected' - case 'connecting': - return 'Connecting' - case 'blocked': - return 'Update needed' - case 'disconnected': - return 'Disconnected' - case 'error': - return 'Needs attention' - case 'mixed': - return 'Mixed' - } + return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health) } /** diff --git a/src/shared/execution-host-health.ts b/src/shared/execution-host-health.ts new file mode 100644 index 000000000000..4d8b12eb2559 --- /dev/null +++ b/src/shared/execution-host-health.ts @@ -0,0 +1,47 @@ +import type { SshConnectionStatus } from './ssh-types' + +export type ExecutionHostHealth = + | 'local' + | 'available' + | 'connecting' + | 'blocked' + | 'disconnected' + | 'error' + +/** An SSH host's health; a target with no lifecycle state has never connected. */ +export function getSshConnectionHealth( + status: SshConnectionStatus | undefined +): ExecutionHostHealth { + switch (status) { + case 'connected': + return 'available' + case 'connecting': + case 'deploying-relay': + case 'reconnecting': + return 'connecting' + case 'auth-failed': + case 'error': + case 'reconnection-failed': + return 'error' + case 'disconnected': + case undefined: + return 'disconnected' + } +} + +export function getExecutionHostHealthLabel(health: ExecutionHostHealth): string { + switch (health) { + case 'local': + return 'Local' + case 'available': + return 'Connected' + case 'connecting': + return 'Connecting' + case 'blocked': + return 'Update needed' + case 'disconnected': + return 'Disconnected' + case 'error': + return 'Needs attention' + } +} diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index 68c7e780b137..8bdc713793fe 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -20,14 +20,9 @@ import type { RuntimeEnvironmentSource } from './runtime-environments' import type { GlobalSettings } from './global-settings-types' import type { Repo } from './repo-types' import { annotateManagedOrcadExecutionHosts } from './managed-orcad-execution-host' +import { getSshConnectionHealth, type ExecutionHostHealth } from './execution-host-health' -export type ExecutionHostHealth = - | 'local' - | 'available' - | 'connecting' - | 'blocked' - | 'disconnected' - | 'error' +export type { ExecutionHostHealth } from './execution-host-health' export type ExecutionHostRegistryEntry = { id: ExecutionHostId @@ -113,24 +108,6 @@ function runtimeControlHealth( } } -function sshHealth(state: SshConnectionState | undefined): ExecutionHostHealth { - switch (state?.status) { - case 'connected': - return 'available' - case 'connecting': - case 'deploying-relay': - case 'reconnecting': - return 'connecting' - case 'auth-failed': - case 'error': - case 'reconnection-failed': - return 'error' - case 'disconnected': - case undefined: - return 'disconnected' - } -} - function setHost( hosts: Map, entry: ExecutionHostRegistryEntry @@ -302,7 +279,7 @@ export function buildExecutionHostRegistry(args: { kind: 'ssh', label: args.sshTargetLabels?.get(targetId) || targetId, detail: 'SSH', - health: sshHealth(state), + health: getSshConnectionHealth(state?.status), connectionStatus: state?.status }) } diff --git a/src/shared/worktree/host-context-labels.ts b/src/shared/worktree/host-context-labels.ts index d3ab9ed2ed73..2171f00d76ab 100644 --- a/src/shared/worktree/host-context-labels.ts +++ b/src/shared/worktree/host-context-labels.ts @@ -40,10 +40,14 @@ export function getHostContextLabel( } /** - * Build labels for SSH targets and apply persisted display-name overrides. - * Target summaries have appeared both as raw target ids and canonical `ssh:` ids - * across protocol versions, so accept either representation. + * The host id an SSH target summary names. Summaries have carried both raw target ids and + * canonical `ssh:` ids across protocol versions, so accept either representation. */ +export function sshTargetSummaryHostId(targetId: string): ExecutionHostId { + return normalizeExecutionHostId(targetId) ?? toSshExecutionHostId(targetId) +} + +/** Build labels for SSH targets and apply persisted display-name overrides. */ export function buildHostLabelById(args: { sshTargets: readonly { id: string; label: string }[] hostSettingOverrides: unknown @@ -54,10 +58,7 @@ export function buildHostLabelById(args: { if (!target.id.trim() || !label) { continue } - const hostId = normalizeExecutionHostId(target.id) ?? toSshExecutionHostId(target.id) - if (hostId) { - labels.set(hostId, label) - } + labels.set(sshTargetSummaryHostId(target.id), label) } const overrides = args.hostSettingOverrides && typeof args.hostSettingOverrides === 'object' From de00a4f03c3e1c9bc0ceda03ecd09b8b87e51634 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:54:09 -0400 Subject: [PATCH 08/58] feat(runtime): let a paired desktop register its phones as phone devices (#26427) * feat(runtime): let a paired desktop register its phones as delegated mobile devices A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(runtime): move the mobile device revoke primitive below dispatch Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in a layer under it as a pure move; this drops the abstract method seam. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): keep a runtime grant until its delegated phones are revoked A failed child cleanup now leaves the parent in place so a retry can still find the children, and revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage, shares credential minting, and drops the timing-dependent census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): fail a delegated sync whose dropped phone cannot be revoked Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors before minting anything, so the desktop retries instead of believing the server matches its list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): refuse pairing admin to phones delegated through a paired desktop A delegated child phone could call the mobile-allowlisted pairing-admin methods (push registration, relay provisioning) that its parent desktop is never granted. The caller scope now marks delegated phones and denies them that permission. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...elegated-mobile-device.integration.test.ts | 354 ++++++++++++++++++ src/main/runtime/delegated-mobile-devices.ts | 43 +++ src/main/runtime/device-credential.ts | 6 + .../runtime/device-registry-row-validation.ts | 39 ++ src/main/runtime/device-registry.ts | 72 ++-- src/main/runtime/rpc/core.ts | 8 + .../runtime/rpc/dispatcher-stream-options.ts | 2 + src/main/runtime/rpc/methods/pairing.ts | 16 + src/main/runtime/rpc/rpc-caller-scope.ts | 13 +- .../runtime-rpc/runtime-rpc-mobile-devices.ts | 117 ++++++ .../runtime-rpc/runtime-rpc-pairing.ts | 73 +--- .../runtime-rpc-websocket-dispatch.ts | 10 +- .../delegated-mobile-device-contract.ts | 34 ++ src/shared/protocol-version.ts | 1 + .../rpc-params-catalog.generated.ts | 2 + 15 files changed, 673 insertions(+), 117 deletions(-) create mode 100644 src/main/runtime/delegated-mobile-device.integration.test.ts create mode 100644 src/main/runtime/delegated-mobile-devices.ts create mode 100644 src/main/runtime/device-credential.ts create mode 100644 src/main/runtime/device-registry-row-validation.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts create mode 100644 src/shared/delegated-mobile-device-contract.ts diff --git a/src/main/runtime/delegated-mobile-device.integration.test.ts b/src/main/runtime/delegated-mobile-device.integration.test.ts new file mode 100644 index 000000000000..1435e5c14661 --- /dev/null +++ b/src/main/runtime/delegated-mobile-device.integration.test.ts @@ -0,0 +1,354 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { readRuntimeMetadata } from './runtime-metadata' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { DeviceRegistry } from './device-registry' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from '../../shared/pairing' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + type DelegatedMobileDeviceSyncResult +} from '../../shared/delegated-mobile-device-contract' +import { sendRequest } from './runtime-rpc-test-harness' +import { + authenticateMobileWsSession, + createEncryptedWsResponseReader, + sendEncryptedWsRequest, + waitForWsClose +} from './runtime-rpc-mobile-ws-test-harness' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +type DelegatedDevice = DelegatedMobileDeviceSyncResult['devices'][number] + +describe('pairing.delegatedMobileDevice.sync', () => { + const cleanups: (() => Promise | void)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + async function startServer(userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-'))) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store carries the surface these RPCs read, as in the sibling WebSocket tests. + const runtime = new OrcaRuntimeService(makeStore() as never) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + return { server, userDataPath, registry: server.getDeviceRegistry()! } + } + + function pair( + server: OrcaRuntimeRpcServer, + args: Parameters[0] + ): PairingOffer { + const offer = server.createPairingOffer({ address: '127.0.0.1', ...args }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + return parsePairingCode(offer.pairingUrl)! + } + + function pairDesktop(server: OrcaRuntimeRpcServer, name = 'MacBook'): PairingOffer { + return pair(server, { name, scope: 'runtime' }) + } + + async function sync( + desktop: PairingOffer, + phones: { phoneKey: string; name: string }[] + ): Promise { + const response = await sendRemoteRuntimeRequest( + desktop, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + { phones }, + 5_000 + ) + if (!response.ok) { + throw new Error(`sync failed: ${response.error.code} ${response.error.message}`) + } + return response.result.devices + } + + function sendSync(desktop: PairingOffer, phones: unknown) { + return sendRemoteRuntimeRequest(desktop, DELEGATED_MOBILE_DEVICE_SYNC_METHOD, { phones }, 5_000) + } + + function asPhone(desktop: PairingOffer, device: DelegatedDevice): PairingOffer { + return { + ...desktop, + deviceToken: device.token, + pairedDeviceId: device.deviceId, + scope: 'mobile' + } + } + + it('advertises the capability a desktop gates on', async () => { + const { server } = await startServer() + const status = await sendRemoteRuntimeRequest<{ capabilities: string[] }>( + pairDesktop(server), + 'status.get', + {}, + 5_000 + ) + expect(status).toMatchObject({ ok: true }) + expect(status.ok && status.result.capabilities).toContain( + DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY + ) + }) + + it('creates an ordinary mobile device a real E2EE client uses, idempotently', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const devices = await sync(desktop, [{ phoneKey: 'phone-1', name: 'iPhone via MacBook' }]) + expect(devices).toEqual([ + { phoneKey: 'phone-1', deviceId: expect.any(String), token: expect.any(String) } + ]) + expect(await sync(desktop, [{ phoneKey: 'phone-1', name: 'iPhone via MacBook' }])).toEqual( + devices + ) + const child = devices[0]! + expect(child.deviceId).not.toBe(desktop.pairedDeviceId) + expect(registry.getDevice(child.deviceId)).toMatchObject({ + name: 'iPhone via MacBook', + scope: 'mobile', + parentDeviceId: desktop.pairedDeviceId, + phoneKey: 'phone-1' + }) + + const phone = asPhone(desktop, child) + await expect(sendRemoteRuntimeRequest(phone, 'status.get', {}, 5_000)).resolves.toMatchObject({ + ok: true, + result: { deviceScope: 'mobile' } + }) + // The mobile allowlist applies, including to sync itself. + await expect( + sendRemoteRuntimeRequest(phone, 'worktree.list', {}, 5_000) + ).resolves.toMatchObject({ ok: false, error: { code: 'forbidden' } }) + await expect( + sendRemoteRuntimeRequest(phone, DELEGATED_MOBILE_DEVICE_SYNC_METHOD, { phones: [] }, 5_000) + ).resolves.toMatchObject({ ok: false, error: { code: 'forbidden' } }) + }) + + it('refuses a directly paired phone and a local caller with no paired device', async () => { + const { server, registry, userDataPath } = await startServer() + const phone = pair(server, { name: 'phone', scope: 'mobile' }) + await expect( + sendRemoteRuntimeRequest( + phone, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + { phones: [{ phoneKey: 'p', name: 'p' }] }, + 5_000 + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'forbidden' } }) + const metadata = readRuntimeMetadata(userDataPath)! + await expect( + sendRequest(metadata.transports[0]!.endpoint, { + id: 'local', + authToken: metadata.authToken, + method: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + params: { phones: [{ phoneKey: 'p', name: 'p' }] } + }) + ).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: 'runtime_device_required' } + }) + expect(registry.listDevices().filter((device) => device.parentDeviceId)).toEqual([]) + }) + + it('refuses a child the pairing admin its parent was never granted', async () => { + const { server } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + const directPhone = pair(server, { name: 'phone', scope: 'mobile' }) + const registerPush = (caller: PairingOffer) => + sendRemoteRuntimeRequest( + caller, + 'notifications.registerPush', + { platform: 'android', token: 'fcm-token', filter: {} }, + 5_000 + ) + + await expect(registerPush(asPhone(desktop, child!))).resolves.toMatchObject({ + ok: false, + error: { code: 'forbidden' } + }) + await expect(registerPush(directPhone)).resolves.toMatchObject({ ok: true }) + }) + + it('gives children the parent grant reach, so they never widen the bind', async () => { + const { server, registry } = await startServer() + const desktop = pair(server, { name: 'local Mac', scope: 'runtime', reach: 'this-computer' }) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + expect(registry.getDevice(child!.deviceId)?.pairingReach).toBe('this-computer') + }) + + it('never lets the QR pending lookups hand out or delete a child', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'phone-1', name: 'iPhone via MacBook' }]) + + expect(registry.getPendingDevice('mobile')).toBeNull() + const offer = server.createPairingOffer({ address: '127.0.0.1', scope: 'mobile' }) + expect(offer.available && offer.deviceId).not.toBe(child!.deviceId) + const rotated = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'mobile', + rotate: true + }) + expect(rotated.available && rotated.deviceId).not.toBe(child!.deviceId) + expect(registry.getDevice(child!.deviceId)).not.toBeNull() + await expect( + sendRemoteRuntimeRequest(asPhone(desktop, child!), 'status.get', {}, 5_000) + ).resolves.toMatchObject({ ok: true }) + }) + + it('revokes a dropped child and closes its socket', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [a, b] = await sync(desktop, [ + { phoneKey: 'phone-a', name: 'A' }, + { phoneKey: 'phone-b', name: 'B' } + ]) + const sessionB = await authenticateMobileWsSession(encodePairingOffer(asPhone(desktop, b!))) + const closedB = waitForWsClose(sessionB.ws) + + expect(await sync(desktop, [{ phoneKey: 'phone-a', name: 'A' }])).toEqual([a]) + expect(registry.getDevice(b!.deviceId)).toBeNull() + await closedB + }) + + it('cascades a parent revoke to its children only', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'phone-a', name: 'A' }]) + // Paired after the first desktop consumed its pending offer; the same phoneKey is scoped per parent. + const otherDesktop = pairDesktop(server, 'Other Mac') + const [otherChild] = await sync(otherDesktop, [{ phoneKey: 'phone-a', name: 'A' }]) + expect(otherChild!.deviceId).not.toBe(child!.deviceId) + + const session = await authenticateMobileWsSession(encodePairingOffer(asPhone(desktop, child!))) + const reader = createEncryptedWsResponseReader(session) + cleanups.push(() => reader.dispose()) + sendEncryptedWsRequest(session, { id: 's', method: 'status.get' }) + await expect(reader.next('s')).resolves.toMatchObject({ ok: true }) + const closed = waitForWsClose(session.ws) + + expect(server.revokeRuntimeAccess(desktop.pairedDeviceId!)).toBe(true) + expect(registry.getDevice(child!.deviceId)).toBeNull() + expect(registry.getDevice(otherChild!.deviceId)).not.toBeNull() + await closed + }) + + it('renames a child in place and rejects duplicate or too many phones', async () => { + const { server, registry, userDataPath } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'iPhone via MacBook' }]) + expect(await sync(desktop, [{ phoneKey: 'p', name: 'iPad via MacBook' }])).toEqual([child]) + expect(new DeviceRegistry(userDataPath).getDevice(child!.deviceId)?.name).toBe( + 'iPad via MacBook' + ) + + const tooMany = Array.from({ length: 33 }, (_, index) => ({ phoneKey: `p${index}`, name: 'n' })) + for (const phones of [ + [ + { phoneKey: 'p', name: 'a' }, + { phoneKey: 'p', name: 'b' } + ], + tooMany + ]) { + await expect(sendSync(desktop, phones)).resolves.toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + } + expect(registry.listDelegatedMobileDevices(desktop.pairedDeviceId!)).toHaveLength(1) + }) + + it('keeps the parent when a child cleanup cannot be saved', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + expect( + server.setMobileRelayBinding(child!.deviceId, { + relayHostId: 'host', + relayDeviceId: child!.deviceId, + ownerIdentityKey: 'owner' + }) + ).toBe(true) + const enqueue = vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => { + throw new Error('disk full') + }) + cleanups.push(() => enqueue.mockRestore()) + + expect(server.revokeRuntimeAccess(desktop.pairedDeviceId!)).toBe(false) + expect(registry.getDevice(desktop.pairedDeviceId!)).not.toBeNull() + expect(registry.getDevice(child!.deviceId)).not.toBeNull() + }) + + it('fails a sync whose dropped child cannot be revoked, keeping that child', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + expect( + server.setMobileRelayBinding(child!.deviceId, { + relayHostId: 'host', + relayDeviceId: child!.deviceId, + ownerIdentityKey: 'owner' + }) + ).toBe(true) + const enqueue = vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => { + throw new Error('disk full') + }) + cleanups.push(() => enqueue.mockRestore()) + + await expect(sendSync(desktop, [{ phoneKey: 'q', name: 'q' }])).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: 'delegated_device_revoke_failed' } + }) + expect(registry.listDelegatedMobileDevices(desktop.pairedDeviceId!)).toMatchObject([ + { deviceId: child!.deviceId } + ]) + }) + + it('keeps the parent link across a restart, so the cascade still applies', async () => { + const first = await startServer() + const desktop = pairDesktop(first.server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + await first.server.stop() + + const reloaded = new DeviceRegistry(first.userDataPath) + expect(reloaded.listDelegatedMobileDevices(desktop.pairedDeviceId!)).toMatchObject([ + { deviceId: child!.deviceId, phoneKey: 'p', parentDeviceId: desktop.pairedDeviceId } + ]) + const { server, registry } = await startServer(first.userDataPath) + expect(server.revokeRuntimeAccess(desktop.pairedDeviceId!)).toBe(true) + expect(registry.getDevice(child!.deviceId)).toBeNull() + }) + + it('loads a registry written before delegated devices existed unchanged', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-old-')) + const old = [ + { deviceId: 'd1', name: 'Phone', token: 't1', scope: 'mobile', pairedAt: 1, lastSeenAt: 2 }, + { deviceId: 'd2', name: 'Mac', token: 't2', scope: 'runtime', pairedAt: 3, lastSeenAt: 4 } + ] + writeFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), JSON.stringify(old)) + const registry = new DeviceRegistry(userDataPath) + expect(registry.listDevices()).toMatchObject(old) + expect(registry.listDevices().every((device) => device.parentDeviceId === undefined)).toBe(true) + expect(registry.listDelegatedMobileDevices('d2')).toEqual([]) + }) +}) diff --git a/src/main/runtime/delegated-mobile-devices.ts b/src/main/runtime/delegated-mobile-devices.ts new file mode 100644 index 000000000000..1919ef2b7e22 --- /dev/null +++ b/src/main/runtime/delegated-mobile-devices.ts @@ -0,0 +1,43 @@ +import { mintDeviceCredential } from './device-credential' +import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract' +import type { DeviceEntry } from './device-registry' + +export type DelegatedMobileDeviceEntry = DeviceEntry & { parentDeviceId: string; phoneKey: string } + +/** The registry after making `parent`'s children cover `phones`, and those children in `phones` order. */ +export function planDelegatedMobileDevices( + devices: DeviceEntry[], + parent: DeviceEntry, + phones: readonly DelegatedPhone[], + now = Date.now() +): { nextDevices: DeviceEntry[]; entries: DelegatedMobileDeviceEntry[] } { + let nextDevices = devices + const entries = phones.map((phone) => { + const existing = nextDevices.find( + (device): device is DelegatedMobileDeviceEntry => + device.parentDeviceId === parent.deviceId && device.phoneKey === phone.phoneKey + ) + if (existing) { + if (existing.name === phone.name) { + return existing + } + const renamed = { ...existing, name: phone.name } + nextDevices = nextDevices.map((device) => (device === existing ? renamed : device)) + return renamed + } + const entry: DelegatedMobileDeviceEntry = { + ...mintDeviceCredential(), + name: phone.name, + scope: 'mobile', + pairedAt: now, + // Why: never pending — the QR lookups select lastSeenAt === 0 and must not hand out or rotate a child. + lastSeenAt: now, + pairingReach: parent.pairingReach ?? 'network', + parentDeviceId: parent.deviceId, + phoneKey: phone.phoneKey + } + nextDevices = [...nextDevices, entry] + return entry + }) + return { nextDevices, entries } +} diff --git a/src/main/runtime/device-credential.ts b/src/main/runtime/device-credential.ts new file mode 100644 index 000000000000..15791b152565 --- /dev/null +++ b/src/main/runtime/device-credential.ts @@ -0,0 +1,6 @@ +import { randomBytes, randomUUID } from 'node:crypto' + +/** A fresh device id and its bearer token; every registry entry is minted here. */ +export function mintDeviceCredential(): { deviceId: string; token: string } { + return { deviceId: randomUUID(), token: randomBytes(24).toString('hex') } +} diff --git a/src/main/runtime/device-registry-row-validation.ts b/src/main/runtime/device-registry-row-validation.ts new file mode 100644 index 000000000000..54c22ef97ad6 --- /dev/null +++ b/src/main/runtime/device-registry-row-validation.ts @@ -0,0 +1,39 @@ +import type { DeviceScope } from '../../shared/runtime-types' +import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' +import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' + +const GRANTABLE: ReadonlySet = new Set(RUNTIME_DEVICE_GRANTS) + +function isRuntimeDeviceGrant(value: unknown): value is RuntimeDeviceGrant { + return typeof value === 'string' && GRANTABLE.has(value) +} + +export function validGrants(value: unknown, scope: DeviceScope): RuntimeDeviceGrant[] | undefined { + if (scope !== 'runtime' || !Array.isArray(value)) { + return undefined + } + const grants = [...new Set(value.filter(isRuntimeDeviceGrant))].sort() + return grants.length > 0 ? grants : undefined +} + +export function validRelayBinding( + value: unknown, + deviceId: string +): RelayDeviceBinding | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const binding = value as Partial + return binding.relayDeviceId === deviceId && + typeof binding.relayHostId === 'string' && + typeof binding.ownerIdentityKey === 'string' + ? { + relayHostId: binding.relayHostId, + relayDeviceId: binding.relayDeviceId, + ownerIdentityKey: binding.ownerIdentityKey, + ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) + ? { inviteExpiresAt: binding.inviteExpiresAt } + : {}) + } + : undefined +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 5e310ff19ce6..78fffa300bf9 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -2,7 +2,6 @@ // (mobile) connections. Each paired device gets its own revocable token so // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. -import { randomBytes, randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { @@ -13,10 +12,17 @@ import { import type { DeviceScope } from '../../shared/runtime-types' import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import { mintDeviceCredential } from './device-credential' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' -import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' +import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract' +import { + planDelegatedMobileDevices, + type DelegatedMobileDeviceEntry +} from './delegated-mobile-devices' +import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission' +import { validGrants, validRelayBinding } from './device-registry-row-validation' import { parseMobilePushRegistration, type MobilePushRegistration @@ -39,48 +45,18 @@ export type DeviceEntry = { // Why: survives a desktop restart so the host can keep pushing without the phone // re-registering. Absent on every registry written before background push existed. pushRegistration?: MobilePushRegistration + // Why: a phone relayed by a paired desktop is that desktop's child; it dies with the parent's grant. + parentDeviceId?: string + phoneKey?: string // Why: administrative permissions are granted only when pairing; absent on older rows means none. grants?: RuntimeDeviceGrant[] } -const GRANTABLE: ReadonlySet = new Set(RUNTIME_DEVICE_GRANTS) - -function isRuntimeDeviceGrant(value: unknown): value is RuntimeDeviceGrant { - return typeof value === 'string' && GRANTABLE.has(value) -} - -function validGrants(value: unknown, scope: DeviceScope): RuntimeDeviceGrant[] | undefined { - if (scope !== 'runtime' || !Array.isArray(value)) { - return undefined - } - const grants = [...new Set(value.filter(isRuntimeDeviceGrant))].sort() - return grants.length > 0 ? grants : undefined -} - function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): boolean { const current = entry.grants ?? [] return current.length === grants.length && current.every((grant) => grants.includes(grant)) } -function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { - if (!value || typeof value !== 'object') { - return undefined - } - const binding = value as Partial - return binding.relayDeviceId === deviceId && - typeof binding.relayHostId === 'string' && - typeof binding.ownerIdentityKey === 'string' - ? { - relayHostId: binding.relayHostId, - relayDeviceId: binding.relayDeviceId, - ownerIdentityKey: binding.ownerIdentityKey, - ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) - ? { inviteExpiresAt: binding.inviteExpiresAt } - : {}) - } - : undefined -} - // Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of // paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection. const LAST_SEEN_FLUSH_DELAY_MS = 250 @@ -120,9 +96,8 @@ export class DeviceRegistry { ): DeviceEntry { const validatedGrants = validGrants(grants, scope) const entry: DeviceEntry = { - deviceId: randomUUID(), + ...mintDeviceCredential(), name, - token: randomBytes(24).toString('hex'), scope, pairedAt: Date.now(), lastSeenAt: 0, @@ -187,10 +162,28 @@ export class DeviceRegistry { pairingReach: RuntimePairingReach = 'network', grants: readonly RuntimeDeviceGrant[] = [] ): DeviceEntry { + // Why: a pending runtime device has no delegated children — its first auth sets lastSeenAt before any sync. const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach, grants) } + listDelegatedMobileDevices(parentDeviceId: string): DeviceEntry[] { + return this.devices.filter((device) => device.parentDeviceId === parentDeviceId) + } + + /** Create-or-return one mobile child per phoneKey under `parent`, in `phones` order. */ + upsertDelegatedMobileDevices( + parent: DeviceEntry, + phones: readonly DelegatedPhone[] + ): DelegatedMobileDeviceEntry[] { + const { nextDevices, entries } = planDelegatedMobileDevices(this.devices, parent, phones) + if (nextDevices !== this.devices) { + this.save(nextDevices) + this.devices = nextDevices + } + return entries + } + removeDevice(deviceId: string): boolean { const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId) if (nextDevices.length === this.devices.length) { @@ -366,7 +359,10 @@ export class DeviceRegistry { pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network', // Why: a malformed row must degrade to "no background push", never fail the load // and strand every paired device. - pushRegistration: parseMobilePushRegistration(device.pushRegistration) + pushRegistration: parseMobilePushRegistration(device.pushRegistration), + parentDeviceId: + typeof device.parentDeviceId === 'string' ? device.parentDeviceId : undefined, + phoneKey: typeof device.phoneKey === 'string' ? device.phoneKey : undefined })) this.registryUnreadable = false } catch (error) { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 85ab2c94f5eb..ac9756c63bd1 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -9,6 +9,10 @@ import type { PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { + DelegatedPhone, + DelegatedMobileDeviceSyncResult +} from '../../../shared/delegated-mobile-device-contract' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' @@ -114,6 +118,10 @@ export type RpcContext = { // Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage. authenticatedCallerFingerprint?: string pairing?: PairingRpcContext + // Why: set only for an authenticated runtime-scope device and bound to it, so it manages only its own phones. + delegatedMobileDevices?: { + sync(phones: readonly DelegatedPhone[]): DelegatedMobileDeviceSyncResult + } // Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths. sendBinary?: (bytes: Uint8Array) => boolean | void // Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 6587825ac82c..9c6b2447be2b 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -16,6 +16,7 @@ export type RpcDispatchStreamingOptions = Pick< | 'clientCapabilities' | 'updateClientCapabilities' | 'pairing' + | 'delegatedMobileDevices' | 'sendBinary' | 'registerBinaryStreamHandler' | 'registerBinaryMessageHandler' @@ -48,6 +49,7 @@ export function rpcContextFromTransport( updateClientCapabilities: options?.updateClientCapabilities, authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, pairing: options?.pairing, + delegatedMobileDevices: options?.delegatedMobileDevices, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, registerBinaryMessageHandler: options?.registerBinaryMessageHandler, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 775bcea75805..e6d366681b81 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -1,4 +1,8 @@ import { defineMethod } from '../core' +import { + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + DelegatedMobileDeviceSyncParamsSchema +} from '../../../../shared/delegated-mobile-device-contract' import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema @@ -26,5 +30,17 @@ export const PAIRING_METHODS = [ } return await ctx.pairing.provisionRelay(params) } + }), + defineMethod({ + name: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + // Why: paired desktops call this and are never granted pairing-admin; children reach no further (rpc-caller-scope.ts). + permission: 'workspace', + params: DelegatedMobileDeviceSyncParamsSchema, + handler: (params, ctx) => { + if (!ctx.delegatedMobileDevices) { + throw new Error('runtime_device_required') + } + return ctx.delegatedMobileDevices.sync(params.phones) + } }) ] diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts index ecb1a0fb4308..9b1bd33c0e31 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -19,7 +19,9 @@ export type { RuntimeDeviceGrant } export type RpcCallerScope = | { kind: 'owner' } - | { kind: 'mobile' } + /** `delegated`: a phone a paired desktop holds a child device for; it reaches no further than that + * desktop, which is never granted `pairing-admin`. */ + | { kind: 'mobile'; delegated?: boolean } | { kind: 'runtime-paired'; grants: readonly RuntimeDeviceGrant[] } /** The `orca` CLI of a remote SSH host, relayed through this host's bridge. `remoteCliControl` * is the per-target opt-in that lets that host's CLI act outside its own terminals. */ @@ -51,9 +53,12 @@ export function denyRpcMethodForCaller( permission: RpcMethodPermission | undefined ): string | null { if (scope.kind === 'mobile') { - return MOBILE_RPC_METHOD_ALLOWLIST.has(methodName) - ? null - : `Method '${methodName}' is not available to mobile clients` + if (!MOBILE_RPC_METHOD_ALLOWLIST.has(methodName)) { + return `Method '${methodName}' is not available to mobile clients` + } + return scope.delegated && permission === 'pairing-admin' + ? `Method '${methodName}' is not available to a phone connected through a paired desktop` + : null } if (scope.kind === 'owner' || permission === undefined) { return null diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts new file mode 100644 index 000000000000..bf5eb52b4cc4 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts @@ -0,0 +1,117 @@ +import type { RelayDeviceBinding, RelayRevokeOutboxItem } from '../relay/relay-revoke-outbox' +import type { + DelegatedPhone, + DelegatedMobileDeviceSyncResult +} from '../../../shared/delegated-mobile-device-contract' +import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' + +// Why: below dispatch so a caller-bound RPC context can revoke and mint mobile devices. +export class RuntimeRpcMobileDevices extends RuntimeRpcRequestAdmission { + private onPushUnregisterQueued?: () => void + + async revokeMobileDevice(deviceId: string): Promise { + return this.revokeMobileDeviceNow(deviceId) + } + + private revokeMobileDeviceNow(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile') { + return false + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return false + } + } + // Why: unpairing must delete the phone's push token at the gateway too, and the + // registration id is only readable while the device row still exists. + this.queuePushUnregister(deviceId, device.pushRegistration?.registrationId) + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + revokeRuntimeAccess(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'runtime') { + return false + } + for (const child of this.deviceRegistry?.listDelegatedMobileDevices(deviceId) ?? []) { + // Why: keep the parent until every child's cleanup is saved, so a retry can still find them. + if (!this.revokeMobileDeviceNow(child.deviceId)) { + return false + } + } + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + /** Make the runtime device `parentDeviceId`'s phones exactly `phones`; dropped ones are fully revoked. */ + protected syncDelegatedMobileDevices( + parentDeviceId: string, + phones: readonly DelegatedPhone[] + ): DelegatedMobileDeviceSyncResult { + const registry = this.deviceRegistry + const parent = registry?.getDevice(parentDeviceId) + if (!registry || parent?.scope !== 'runtime') { + throw new Error('delegated_device_parent_unavailable') + } + const wanted = new Set(phones.map((phone) => phone.phoneKey)) + for (const child of registry.listDelegatedMobileDevices(parentDeviceId)) { + if ( + (!child.phoneKey || !wanted.has(child.phoneKey)) && + !this.revokeMobileDeviceNow(child.deviceId) + ) { + throw new Error('delegated_device_revoke_failed') + } + } + return { + devices: registry.upsertDelegatedMobileDevices(parent, phones).map((device) => ({ + phoneKey: device.phoneKey, + deviceId: device.deviceId, + token: device.token + })) + } + } + + /** Best-effort: a failed enqueue must never block the revoke the user asked for. */ + protected queuePushUnregister(deviceId: string, registrationId: string | undefined): void { + if (!registrationId) { + return + } + try { + this.pushUnregisterOutbox.enqueue({ registrationId, deviceId }) + this.onPushUnregisterQueued?.() + } catch (error) { + console.error('[runtime] Failed to persist a push token cleanup:', error) + } + } + + setOnPushUnregisterQueued(callback: (() => void) | null): void { + this.onPushUnregisterQueued = callback ?? undefined + } + + protected queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + console.error('[runtime] Failed to persist Relay device cleanup:', error) + return false + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch (error) { + console.warn('[runtime] Failed to notify Relay cleanup worker:', error) + } + return true + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 1cd226606522..7c60b590effb 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -2,11 +2,7 @@ import type { RuntimeDeviceGrant } from '../rpc/rpc-caller-scope' import type { DeviceEntry, DeviceRegistry, DeviceScope } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' -import type { - RelayDeviceBinding, - RelayRevokeOutbox, - RelayRevokeOutboxItem -} from '../relay/relay-revoke-outbox' +import type { RelayDeviceBinding, RelayRevokeOutbox } from '../relay/relay-revoke-outbox' import type { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' import type { RuntimePairingReach } from '../../../shared/runtime-pairing-reach' @@ -22,8 +18,6 @@ import { } from './runtime-rpc-pairing-types' export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { - private onPushUnregisterQueued?: () => void - getDeviceRegistry(): DeviceRegistry | null { return this.deviceRegistry } @@ -90,38 +84,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayPairingProviderInstaller = install } - async revokeMobileDevice(deviceId: string): Promise { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'mobile') { - return false - } - if (device.relayBinding) { - if (!this.queueRelayDeviceRevoke(device.relayBinding)) { - return false - } - } - // Why: unpairing must delete the phone's push token at the gateway too, and the - // registration id is only readable while the device row still exists. - this.queuePushUnregister(deviceId, device.pushRegistration?.registrationId) - if (!this.deviceRegistry?.removeDevice(deviceId)) { - return false - } - this.mobileRelayPairingProvider?.onDemandStateChanged?.() - this.runtime.forgetClientNavigationState(deviceId) - this.mobileSocketWiring?.terminateDeviceConnections(device.token) - return true - } - - revokeRuntimeAccess(deviceId: string): boolean { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { - return false - } - this.runtime.forgetClientNavigationState(deviceId) - this.mobileSocketWiring?.terminateDeviceConnections(device.token) - return true - } - getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null @@ -203,23 +165,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { } } - /** Best-effort: a failed enqueue must never block the revoke the user asked for. */ - protected queuePushUnregister(deviceId: string, registrationId: string | undefined): void { - if (!registrationId) { - return - } - try { - this.pushUnregisterOutbox.enqueue({ registrationId, deviceId }) - this.onPushUnregisterQueued?.() - } catch (error) { - console.error('[runtime] Failed to persist a push token cleanup:', error) - } - } - - setOnPushUnregisterQueued(callback: (() => void) | null): void { - this.onPushUnregisterQueued = callback ?? undefined - } - protected queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { if (this.queueRelayDeviceRevoke(binding)) { return @@ -230,20 +175,4 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) } } - - protected queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { - let item: RelayRevokeOutboxItem - try { - item = this.relayRevokeOutbox.enqueue(binding) - } catch (error) { - console.error('[runtime] Failed to persist Relay device cleanup:', error) - return false - } - try { - this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) - } catch (error) { - console.warn('[runtime] Failed to notify Relay cleanup worker:', error) - } - return true - } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 67869763db85..e761255222c2 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -8,7 +8,7 @@ import type { AuthenticatedMobileSocket } from '../rpc/mobile-socket-wiring' import type { RpcRequest, RpcResponse } from '../rpc/core' import type { WebSocketTransport } from '../rpc/ws-transport' import type { DeviceScope } from '../device-registry' -import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' +import { RuntimeRpcMobileDevices } from './runtime-rpc-mobile-devices' import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' import type { RpcCallerScope } from '../rpc/rpc-caller-scope' import { limitRuntimeRpcReplySize } from './runtime-rpc-reply-size-limit' @@ -27,7 +27,7 @@ function injectDeviceScope(response: string, scope: DeviceScope): string { } } -export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { +export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. protected async handleWebSocketMessage( rawMessage: string, @@ -77,7 +77,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { // Why: the dispatcher enforces this scope's permissions; it never trusts a request field. const callerScope: RpcCallerScope = device.scope === 'mobile' - ? { kind: 'mobile' } + ? { kind: 'mobile', delegated: device.parentDeviceId !== undefined } : { kind: 'runtime-paired', grants: device.grants ?? [] } // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. @@ -146,6 +146,10 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { } : undefined, pairing: pairingContext, + delegatedMobileDevices: + device.scope === 'runtime' + ? { sync: (phones) => this.syncDelegatedMobileDevices(device.deviceId, phones) } + : undefined, signal: abortRegistration?.signal, sendBinary, registerBinaryStreamHandler: (streamId, handler) => diff --git a/src/shared/delegated-mobile-device-contract.ts b/src/shared/delegated-mobile-device-contract.ts new file mode 100644 index 000000000000..9a0032eee273 --- /dev/null +++ b/src/shared/delegated-mobile-device-contract.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import type { RUNTIME_CAPABILITIES } from './protocol-version' + +// Why: a paired desktop relays each of its phones to this host as an ordinary phone device it owns. +export const DELEGATED_MOBILE_DEVICE_SYNC_METHOD = 'pairing.delegatedMobileDevice.sync' +// Why: a desktop relays phones only to a host advertising this; `satisfies` ties it to RUNTIME_CAPABILITIES. +export const DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY = + 'pairing.delegated-mobile-devices.v1' satisfies (typeof RUNTIME_CAPABILITIES)[number] +export const DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES = 32 + +const DelegatedPhoneSchema = z + .object({ + phoneKey: z.string().min(1).max(128), + name: z.string().min(1).max(128) + }) + .strict() + +export const DelegatedMobileDeviceSyncParamsSchema = z + .object({ + phones: z + .array(DelegatedPhoneSchema) + .max(DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES) + .refine((phones) => new Set(phones.map((phone) => phone.phoneKey)).size === phones.length, { + message: 'duplicate phoneKey' + }) + }) + .strict() + +export type DelegatedPhone = z.infer +export type DelegatedMobileDeviceSyncParams = z.infer + +export type DelegatedMobileDeviceSyncResult = { + devices: { phoneKey: string; deviceId: string; token: string }[] +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 075e6a26d297..c63eca1e11c2 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -436,6 +436,7 @@ export const RUNTIME_CAPABILITIES = [ ...SKILL_RUNTIME_CAPABILITIES, ...AUTOMATION_RUNTIME_CAPABILITIES, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + 'pairing.delegated-mobile-devices.v1', PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index c3aa97088621..5955b80dafee 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -18,6 +18,7 @@ import { BrowserNetworkTunnelAttachParams } from '../browser-client-host-protocol' import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol' +import { DelegatedMobileDeviceSyncParamsSchema } from '../delegated-mobile-device-contract' import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema @@ -1109,6 +1110,7 @@ export const RPC_PARAMS_BY_METHOD = { 'orchestration.workerStart': WorkerStartParams, 'orchestration.workerStop': WorkerDispatchParamsOfOrchestrationWorkerStopParams, 'orchestration.workerTerminalUserInput': OrchestrationWorkerTerminalUserInputParams, + 'pairing.delegatedMobileDevice.sync': DelegatedMobileDeviceSyncParamsSchema, 'pairing.getEndpoints': PairingGetEndpointsParamsSchema, 'pairing.provisionRelay': PairingProvisionRelayParamsSchema, 'plugins.consent': pluginConsentRequestSchema, From 08c8d30f105f96093ca547d36764e4aa266b92cf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:54:27 -0400 Subject: [PATCH 09/58] fix(e2ee): stop dropping terminal output on a backed-up legacy connection (#26528) * fix(browser): retry a screencast frame a viewer's socket refused An idle page sends no next frame, so a refused last frame left the viewer on a stale picture. The desktop's relay leg to a server uses legacy E2EE, which drops on a full socket where a direct v2 phone queues; retrying the newest pending frame restores parity. Retries never count toward ghost eviction. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(e2ee): queue legacy binary replies with text instead of dropping them Legacy E2EE (every direct phone, the desktop's server link) sent binary straight to the socket and silently dropped it once the outbound budget was full, so terminals lost output and binary could overtake queued text. Binary now shares the ordered text queue and closes on overflow like v2; the lossy screencast opts into dropWhenBacklogged and its retry delivers the newest frame after a drain. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(e2ee): keep a backlogged screencast viewer instead of evicting it as a ghost A lossy frame skipped on a busy socket returned false like a dead socket, so a long backlog evicted a live viewer. Skips now return 'backlogged', which retries without counting toward eviction; legacy judges idleness on the sealed size before encrypting, so skipped frames cost no encryption. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(browser): expect the ready gate to forward screencast send options The pre-ready gate now passes the per-frame send options through to the socket sender, so the replayed startup frame arrives with an options slot. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(browser): share the streamed-guest double across screencast tests Main's renderer throttle lease now calls setBackgroundThrottling on the guest, so the bare { isDestroyed } guest in two branch tests threw. One shared double keeps all three screencast suites on the shape the lease needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...er-screencast-subscriber-frame-delivery.ts | 57 ++++++++ ...me-browser-screencast-e2ee-backlog.test.ts | 126 ++++++++++++++++ ...-runtime-browser-screencast-fanout.test.ts | 24 ++-- ...ser-screencast-pending-frame-retry.test.ts | 136 ++++++++++++++++++ src/main/runtime/orca-runtime-browser.test.ts | 4 +- .../mobile-creation-and-orchestration.spec.ts | 2 +- ...browser-screencast-frame-admission.test.ts | 20 ++- ...mote-browser-screencast-frame-admission.ts | 15 +- src/main/runtime/rpc/core.ts | 3 +- .../e2ee-channel-text-backpressure.test.ts | 112 ++++++++++++++- src/main/runtime/rpc/e2ee-channel-v2.test.ts | 22 +++ src/main/runtime/rpc/e2ee-channel.test.ts | 3 +- src/main/runtime/rpc/e2ee-channel.ts | 40 +++--- .../terminal-multiplex-frame-delivery.ts | 3 +- .../rpc/mobile-e2ee-desktop-outbound-owner.ts | 76 ++++++---- .../rpc/mobile-e2ee-outbound-admission.ts | 13 +- .../rpc/mobile-e2ee-v2-desktop-outbound.ts | 18 ++- src/main/runtime/rpc/mobile-socket-wiring.ts | 3 +- src/main/runtime/rpc/rpc-binary-sender.ts | 12 ++ .../runtime-browser-commands-browser-click.ts | 2 + ...-commands-browser-command-target-params.ts | 4 +- ...ime-browser-commands-browser-screencast.ts | 23 +-- .../runtime-browser-screencast-controller.ts | 7 +- .../runtime-edge-command-controller.ts | 3 +- .../runtime-rpc-websocket-dispatch.ts | 3 +- ...-page-browser-commands-host-test-double.ts | 9 ++ src/shared/ws-outbound-backpressure-queue.ts | 16 ++- 27 files changed, 639 insertions(+), 117 deletions(-) create mode 100644 src/main/runtime/browser-screencast-subscriber-frame-delivery.ts create mode 100644 src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts create mode 100644 src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts create mode 100644 src/main/runtime/rpc/rpc-binary-sender.ts diff --git a/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts b/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts new file mode 100644 index 000000000000..1e6621acc208 --- /dev/null +++ b/src/main/runtime/browser-screencast-subscriber-frame-delivery.ts @@ -0,0 +1,57 @@ +import type { ActiveBrowserScreencastSubscriber } from './runtime-browser-commands-browser-command-target-params' +import { recordScreencastSubscriberSend } from './browser-screencast-ghost-subscriber-eviction' +import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' + +// Why 50: the frame pacer's own backpressure retry interval. +export const SCREENCAST_PENDING_FRAME_RETRY_MS = 50 + +/** + * Sends one frame to one viewer. A frame its socket did not take is kept as the viewer's newest + * pending frame and retried until taken: an idle page produces no next frame, so without the retry + * a viewer behind a briefly full socket keeps a stale picture until the page changes. + */ +export function deliverScreencastSubscriberFrame( + subscriber: ActiveBrowserScreencastSubscriber, + bytes: Uint8Array +): void { + const outcome = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) + subscriber.pendingFrame = outcome === 'handled' ? null : bytes + // Why: a backlogged socket is alive and busy, so only a refusal is evidence of a ghost viewer. + if (outcome !== 'backlogged') { + subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, outcome === 'handled') + } + if (outcome !== 'handled') { + schedulePendingFrameRetry(subscriber) + } +} + +/** Paths that remove a viewer that may have received frames call this, so no retry outlives it. */ +export function cancelScreencastSubscriberFrameRetry( + subscriber: ActiveBrowserScreencastSubscriber +): void { + if (subscriber.pendingFrameRetry) { + clearTimeout(subscriber.pendingFrameRetry) + subscriber.pendingFrameRetry = null + } + subscriber.pendingFrame = null +} + +function schedulePendingFrameRetry(subscriber: ActiveBrowserScreencastSubscriber): void { + if (subscriber.pendingFrameRetry) { + return + } + subscriber.pendingFrameRetry = setTimeout(() => { + subscriber.pendingFrameRetry = null + const bytes = subscriber.pendingFrame + if (!bytes) { + return + } + // Why a refused retry is not recorded: ghost eviction must advance only on produced frames. + if (sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) === 'handled') { + subscriber.pendingFrame = null + subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, true) + } else { + schedulePendingFrameRetry(subscriber) + } + }, SCREENCAST_PENDING_FRAME_RETRY_MS) +} diff --git a/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts b/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts new file mode 100644 index 000000000000..b8df6851daea --- /dev/null +++ b/src/main/runtime/orca-runtime-browser-screencast-e2ee-backlog.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction' +import { SCREENCAST_PENDING_FRAME_RETRY_MS } from './browser-screencast-subscriber-frame-delivery' +import { + createSinglePageBrowserCommandsHost, + createStreamedGuest +} from './single-page-browser-commands-host-test-double' +import { E2EEChannel } from './rpc/e2ee-channel' +import { decrypt, decryptBytes, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' + +const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ + webContentsFromId: vi.fn(), + startBrowserScreencast: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: webContentsFromId } +})) +vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +/** A real legacy E2EE channel, as every directly connected phone uses. */ +function connectLegacyChannel() { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const sent: (string | Buffer)[] = [] + const ws = { + OPEN: 1 as const, + readyState: 1, + bufferedAmount: 0, + send: vi.fn((data: string | Buffer) => sent.push(data)), + close: vi.fn() + } + const onError = vi.fn() + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + resolveAuthenticatedDevice: (token) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'mobile' } + : null, + onReady: vi.fn(), + onError + }) + const sharedKey = deriveSharedKey(clientKeys.secretKey, serverKeys.publicKey) + channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(clientKeys.publicKey).toString('base64') + }) + ) + channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + let reply!: (response: string) => void + let sendBinary!: RpcBinarySender + channel.onMessage((_plaintext, textReply, binaryReply) => { + reply = textReply + sendBinary = binaryReply + }) + channel.handleRawMessage(encrypt('{"id":"s","method":"browser.screencast"}', sharedKey)) + const decode = (frame: string | Buffer): string => + typeof frame === 'string' + ? decrypt(frame, sharedKey)! + : `binary:${Buffer.from(decryptBytes(new Uint8Array(frame), sharedKey)!).join(',')}` + return { ws, sent, onError, reply, sendBinary, decode, channel } +} + +async function subscribe(sendBinary: RpcBinarySender) { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + let resolveDone!: () => void + const done = new Promise((resolve) => { + resolveDone = resolve + }) + const stop = vi.fn(() => resolveDone()) + startBrowserScreencast.mockResolvedValue({ stop, done, updateViewport: vi.fn(async () => {}) }) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) + const subscription = await commands.browserScreencast( + { worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' }, + { sendBinary } + ) + const onFrame: (bytes: Uint8Array) => unknown = + startBrowserScreencast.mock.calls[0][1].onFrame + return { subscription, onFrame, stop } +} + +describe('screencast over a backlogged legacy E2EE socket', () => { + beforeEach(() => { + vi.useFakeTimers() + webContentsFromId.mockReset() + webContentsFromId.mockReturnValue(createStreamedGuest()) + startBrowserScreencast.mockReset() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Why: a busy socket is alive; evicting its viewer as a ghost would blank a working phone. + it('keeps the viewer through a long backlog and then delivers only the newest frame', async () => { + const transport = connectLegacyChannel() + const { subscription, onFrame, stop } = await subscribe(transport.sendBinary) + onFrame(new Uint8Array([0])) + const baseline = transport.sent.length + + transport.ws.bufferedAmount = 9 * 1024 * 1024 + transport.reply('{"parked":true}') + const frameCount = BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT + 10 + for (let frame = 1; frame <= frameCount; frame++) { + onFrame(new Uint8Array([frame])) + } + expect(stop).not.toHaveBeenCalled() + expect(transport.sent.length).toBe(baseline) + + transport.ws.bufferedAmount = 0 + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS * 2) + expect(transport.sent.slice(baseline).map(transport.decode)).toEqual([ + '{"parked":true}', + `binary:${frameCount}` + ]) + expect(transport.onError).not.toHaveBeenCalled() + subscription.session.stop() + transport.channel.destroy() + }) +}) diff --git a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts index 863f7aee302a..abdfacac33c4 100644 --- a/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts +++ b/src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction' import type { RuntimeBrowserCommands } from './orca-runtime-browser' -import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double' +import { + createSinglePageBrowserCommandsHost, + createStreamedGuest +} from './single-page-browser-commands-host-test-double' const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ webContentsFromId: vi.fn(), @@ -23,15 +26,6 @@ function deferred() { return { promise, resolve } } -// A guest the page stream keeps painting while it streams. -function createStreamedGuest() { - return { - isDestroyed: () => false, - setBackgroundThrottling: vi.fn(), - capturePage: vi.fn(async () => null) - } -} - describe('RuntimeBrowserCommands screencast fanout', () => { beforeEach(() => { webContentsFromId.mockReset() @@ -72,8 +66,8 @@ describe('RuntimeBrowserCommands screencast fanout', () => { const frame = new Uint8Array([1, 2, 3]) expect(startBrowserScreencast).toHaveBeenCalledOnce() expect(startBrowserScreencast.mock.calls[0][1].onFrame(frame)).toBe(true) - expect(firstSend).toHaveBeenCalledWith(frame) - expect(secondSend).toHaveBeenCalledWith(frame) + expect(firstSend).toHaveBeenCalledWith(frame, { dropWhenBacklogged: true }) + expect(secondSend).toHaveBeenCalledWith(frame, { dropWhenBacklogged: true }) expect(updateViewport).toHaveBeenLastCalledWith( expect.objectContaining({ viewportWidth: 800, viewportHeight: 600 }) ) @@ -167,12 +161,12 @@ describe('RuntimeBrowserCommands screencast fanout', () => { { sendBinary: joinerSend } ) - expect(creatorSend).toHaveBeenCalledWith(snapshot) - expect(joinerSend).toHaveBeenCalledWith(snapshot) + expect(creatorSend).toHaveBeenCalledWith(snapshot, { dropWhenBacklogged: true }) + expect(joinerSend).toHaveBeenCalledWith(snapshot, { dropWhenBacklogged: true }) joinerSend.mockClear() gateOpen = true joiner.flushPendingFrame() - expect(joinerSend).toHaveBeenCalledExactlyOnceWith(snapshot) + expect(joinerSend).toHaveBeenCalledExactlyOnceWith(snapshot, { dropWhenBacklogged: true }) // The accepted replay is not retained, so a second flush cannot duplicate it. joinerSend.mockClear() diff --git a/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts b/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts new file mode 100644 index 000000000000..2fbf9927e690 --- /dev/null +++ b/src/main/runtime/orca-runtime-browser-screencast-pending-frame-retry.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction' +import { SCREENCAST_PENDING_FRAME_RETRY_MS } from './browser-screencast-subscriber-frame-delivery' +import { + createSinglePageBrowserCommandsHost, + createStreamedGuest +} from './single-page-browser-commands-host-test-double' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' + +const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({ + webContentsFromId: vi.fn(), + startBrowserScreencast: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: webContentsFromId } +})) +vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast })) + +async function subscribe(sendBinary: RpcBinarySender) { + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + let resolveDone!: () => void + const done = new Promise((resolve) => { + resolveDone = resolve + }) + const stop = vi.fn(() => resolveDone()) + startBrowserScreencast.mockResolvedValue({ + stop, + done, + updateViewport: vi.fn(async () => {}) + }) + const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost()) + const subscription = await commands.browserScreencast( + { worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' }, + { sendBinary } + ) + const onFrame: (bytes: Uint8Array) => unknown = + startBrowserScreencast.mock.calls[0][1].onFrame + return { subscription, onFrame, stop } +} + +describe('screencast frames a viewer refused', () => { + beforeEach(() => { + vi.useFakeTimers() + webContentsFromId.mockReset() + webContentsFromId.mockReturnValue(createStreamedGuest()) + startBrowserScreencast.mockReset() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Why: an idle page sends no next frame, so a refused last frame must reach the viewer once its + // socket drains, or a relayed phone (whose server leg drops on a full socket) keeps a stale page. + it('delivers the last refused frame once the socket drains, with no new frame produced', async () => { + let socketFull = true + const received: Uint8Array[] = [] + const { subscription, onFrame } = await subscribe((bytes) => { + if (socketFull) { + return false + } + received.push(bytes) + return true + }) + const first = new Uint8Array([1]) + const last = new Uint8Array([2]) + onFrame(first) + onFrame(last) + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS * 4) + expect(received).toEqual([]) + + socketFull = false + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS) + expect(received).toEqual([last]) + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS * 4) + expect(received).toEqual([last]) + subscription.session.stop() + }) + + it('retries the newest refused frame when a newer one is refused mid-retry', async () => { + let socketFull = true + const received: Uint8Array[] = [] + const { subscription, onFrame } = await subscribe((bytes) => { + if (socketFull) { + return false + } + received.push(bytes) + return true + }) + const stale = new Uint8Array([1]) + const newest = new Uint8Array([2]) + onFrame(stale) + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS * 2) + onFrame(newest) + socketFull = false + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS * 4) + expect(received).toEqual([newest]) + subscription.session.stop() + }) + + // Why: a lossy frame must not queue behind a backlog; the retry above delivers the newest instead. + it('asks the transport to drop frames while the socket is backlogged', async () => { + const sendBinary = vi.fn(() => true) + const { subscription, onFrame } = await subscribe(sendBinary) + onFrame(new Uint8Array([1])) + expect(sendBinary).toHaveBeenCalledWith(new Uint8Array([1]), { dropWhenBacklogged: true }) + subscription.session.stop() + }) + + it('stops retrying once the viewer leaves', async () => { + const sendBinary = vi.fn(() => false) + const { subscription, onFrame } = await subscribe(sendBinary) + onFrame(new Uint8Array([1])) + subscription.session.stop() + await subscription.session.done + sendBinary.mockClear() + await vi.advanceTimersByTimeAsync(SCREENCAST_PENDING_FRAME_RETRY_MS * 4) + expect(sendBinary).not.toHaveBeenCalled() + }) + + it('does not count refused retries toward ghost eviction', async () => { + let refusing = false + const { subscription, onFrame, stop } = await subscribe(() => !refusing) + onFrame(new Uint8Array([0])) + refusing = true + onFrame(new Uint8Array([1])) + await vi.advanceTimersByTimeAsync( + SCREENCAST_PENDING_FRAME_RETRY_MS * BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT * 2 + ) + // Eviction is judged when a frame is produced. + onFrame(new Uint8Array([2])) + expect(stop).not.toHaveBeenCalled() + subscription.session.stop() + }) +}) diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 27accca72bf2..a2d9569650de 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -669,8 +669,8 @@ describe('RuntimeBrowserCommands browser screencast', () => { expect(first.subscriptionId).not.toBe(second.subscriptionId) const frame = new Uint8Array([1, 2, 3]) expect(startBrowserScreencastMock.mock.calls[0][1].onFrame(frame)).toBe(true) - expect(firstSend).toHaveBeenCalledWith(frame) - expect(secondSend).toHaveBeenCalledWith(frame) + expect(firstSend).toHaveBeenCalledWith(frame, { dropWhenBacklogged: true }) + expect(secondSend).toHaveBeenCalledWith(frame, { dropWhenBacklogged: true }) expect(updateViewport).toHaveBeenLastCalledWith( expect.objectContaining({ viewportWidth: 800, viewportHeight: 600 }) ) diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration.spec.ts index 05101384754f..9357f7248f7b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration.spec.ts @@ -672,7 +672,7 @@ describe('OrcaRuntimeService', () => { ) // The gate still refuses the frame on arrival; it reaches the client only via the // post-ready replay, so a static page does not leave the subscriber frameless. - expect(sendBinary).toHaveBeenCalledExactlyOnceWith(startupFrame) + expect(sendBinary).toHaveBeenCalledExactlyOnceWith(startupFrame, undefined) runtime.cleanupSubscription('browser-screencast:page-1:first') await task diff --git a/src/main/runtime/remote-browser-screencast-frame-admission.test.ts b/src/main/runtime/remote-browser-screencast-frame-admission.test.ts index 8f0f1e83081c..e29d58d045a0 100644 --- a/src/main/runtime/remote-browser-screencast-frame-admission.test.ts +++ b/src/main/runtime/remote-browser-screencast-frame-admission.test.ts @@ -7,6 +7,7 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/rem import { startBrowserScreencast } from '../browser/browser-screencast-stream' import { E2EEChannel } from './rpc/e2ee-channel' import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' function createMockWebContents() { let attached = false @@ -58,7 +59,7 @@ function createRuntimeBinarySender() { channel.handleRawMessage( encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) ) - let sendBinary: ((bytes: Uint8Array) => boolean | void) | undefined + let sendBinary: RpcBinarySender | undefined channel.onMessage((_plaintext, _sendText, sendBinaryReply) => { sendBinary = sendBinaryReply }) @@ -74,7 +75,7 @@ describe('sendRemoteBrowserScreencastFrame', () => { const sendBinary = vi.fn(() => true) const oversized = new Uint8Array(REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 1) - expect(sendRemoteBrowserScreencastFrame(sendBinary, oversized)).toBe(true) + expect(sendRemoteBrowserScreencastFrame(sendBinary, oversized)).toBe('handled') expect(sendBinary).not.toHaveBeenCalled() }) @@ -86,14 +87,20 @@ describe('sendRemoteBrowserScreencastFrame', () => { vi.fn(() => false), withinLimit ) - ).toBe(false) + ).toBe('refused') expect( sendRemoteBrowserScreencastFrame( vi.fn(() => true), withinLimit ) - ).toBe(true) - expect(sendRemoteBrowserScreencastFrame(vi.fn(), withinLimit)).toBe(true) + ).toBe('handled') + expect(sendRemoteBrowserScreencastFrame(vi.fn(), withinLimit)).toBe('handled') + expect( + sendRemoteBrowserScreencastFrame( + vi.fn(() => 'backlogged' as const), + withinLimit + ) + ).toBe('backlogged') }) it('drops an oversized encoded frame without closing or stalling the paired stream', async () => { @@ -107,7 +114,8 @@ describe('sendRemoteBrowserScreencastFrame', () => { maxHeight: 2160, everyNthFrame: 1, minFrameIntervalMs: 0, - onFrame: (bytes) => sendRemoteBrowserScreencastFrame(transport.sendBinary, bytes) + onFrame: (bytes) => + sendRemoteBrowserScreencastFrame(transport.sendBinary, bytes) !== 'refused' }) try { diff --git a/src/main/runtime/remote-browser-screencast-frame-admission.ts b/src/main/runtime/remote-browser-screencast-frame-admission.ts index 6e4d1255b2ac..395c1bd0163b 100644 --- a/src/main/runtime/remote-browser-screencast-frame-admission.ts +++ b/src/main/runtime/remote-browser-screencast-frame-admission.ts @@ -1,14 +1,19 @@ import { isRemoteRuntimeBinaryFrameWithinLimit } from '../../shared/remote-runtime-memory-limits' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' + +/** 'backlogged': a live socket skipped the frame; 'refused': the transport would not take it. */ +export type ScreencastFrameSendOutcome = 'handled' | 'backlogged' | 'refused' // Why: the E2EE channel closes the socket (1013) on an over-limit binary frame, and the -// screencast producer reads `false` as backpressure and retries the identical frame. Reporting +// screencast producer reads a refusal as backpressure and retries the identical frame. Reporting // an over-limit frame as handled drops it so the stream advances instead of retrying forever. export function sendRemoteBrowserScreencastFrame( - sendBinary: (bytes: Uint8Array) => boolean | void, + sendBinary: RpcBinarySender, bytes: Uint8Array -): boolean { +): ScreencastFrameSendOutcome { if (!isRemoteRuntimeBinaryFrameWithinLimit(bytes)) { - return true + return 'handled' } - return sendBinary(bytes) !== false + const sent = sendBinary(bytes, { dropWhenBacklogged: true }) + return sent === 'backlogged' ? 'backlogged' : sent === false ? 'refused' : 'handled' } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index ac9756c63bd1..86226f83522f 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -16,6 +16,7 @@ import type { import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' +import type { RpcBinarySender } from './rpc-binary-sender' import type { RpcMethodPermission } from './rpc-method-permission' export type PairingRpcContext = { @@ -123,7 +124,7 @@ export type RpcContext = { sync(phones: readonly DelegatedPhone[]): DelegatedMobileDeviceSyncResult } // Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths. - sendBinary?: (bytes: Uint8Array) => boolean | void + sendBinary?: RpcBinarySender // Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created. registerBinaryStreamHandler?: ( streamId: number, diff --git a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts index 5761ac504aa6..c88c430ecd85 100644 --- a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebSocket } from 'ws' +import type { RpcBinarySendResult } from './rpc-binary-sender' import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' -import { deriveSharedKey, decrypt, encrypt, generateKeyPair } from './e2ee-crypto' +import { deriveSharedKey, decrypt, decryptBytes, encrypt, generateKeyPair } from './e2ee-crypto' import { createMobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' @@ -20,12 +21,12 @@ function publicKeyToBase64(key: Uint8Array): string { } function createMockWs() { - const sent: string[] = [] + const sent: (string | Buffer)[] = [] return { OPEN: 1 as const, readyState: 1, bufferedAmount: 0, - send: vi.fn((data: string) => { + send: vi.fn((data: string | Buffer) => { sent.push(data) }), close: vi.fn(), @@ -93,7 +94,9 @@ describe('E2EE text reply backpressure', () => { ctx.ws.bufferedAmount = 0 vi.runOnlyPendingTimers() - const replies = ctx.ws.sent.slice(baseline).map((frame) => decrypt(frame, ctx.sharedKey)) + const replies = ctx.ws.sent + .slice(baseline) + .map((frame) => decrypt(String(frame), ctx.sharedKey)) expect(replies).toEqual(['{"seq":1}', '{"seq":2}', '{"seq":3}']) expect(ctx.onError).not.toHaveBeenCalled() }) @@ -103,7 +106,7 @@ describe('E2EE text reply backpressure', () => { const baseline = ctx.ws.sent.length emitReply(ctx, '{"ok":true}') expect(ctx.ws.sent.length).toBe(baseline + 1) - expect(decrypt(ctx.ws.sent[baseline]!, ctx.sharedKey)).toBe('{"ok":true}') + expect(decrypt(String(ctx.ws.sent[baseline]), ctx.sharedKey)).toBe('{"ok":true}') }) it('still closes an oversized reply when telemetry throws', () => { @@ -143,3 +146,102 @@ describe('E2EE text reply backpressure', () => { expect(outboundMemoryBudget.evidence().queuedBytes).toBe(0) }) }) + +describe('E2EE legacy binary reply backpressure', () => { + beforeEach(() => { + vi.useFakeTimers() + trackMock.mockReset() + }) + afterEach(() => { + vi.useRealTimers() + }) + + function decodeSent(ctx: ReturnType, frame: string | Buffer): string { + return typeof frame === 'string' + ? decrypt(frame, ctx.sharedKey)! + : `binary:${Buffer.from(decryptBytes(new Uint8Array(frame), ctx.sharedKey)!).toString()}` + } + + // Why: legacy terminal.subscribe has no seq-gap check, so a dropped or reordered binary frame corrupts the phone's screen. + it('holds binary replies in one order with text while the outbound budget is full', () => { + const outboundMemoryBudget = createMobileE2EEOutboundMemoryBudget({ maxBufferedBytes: 1_000 }) + const ctx = setup({ outboundMemoryBudget }) + const baseline = ctx.ws.sent.length + ctx.ws.bufferedAmount = 1_001 + const accepted: RpcBinarySendResult[] = [] + + ctx.channel.onMessage((_plaintext, encryptedReply, encryptedBinaryReply) => { + encryptedReply('{"seq":1}') + accepted.push(encryptedBinaryReply(Buffer.from('2'))) + encryptedReply('{"seq":3}') + accepted.push(encryptedBinaryReply(Buffer.from('4'))) + }) + ctx.channel.handleRawMessage(encrypt('{"id":"x","method":"terminal.subscribe"}', ctx.sharedKey)) + expect(ctx.ws.sent.length).toBe(baseline) + + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + + expect(accepted).toEqual([true, true]) + expect(ctx.ws.sent.slice(baseline).map((frame) => decodeSent(ctx, frame))).toEqual([ + '{"seq":1}', + 'binary:2', + '{"seq":3}', + 'binary:4' + ]) + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('closes for budget instead of dropping when queued binary overflows', () => { + const outboundMemoryBudget = createMobileE2EEOutboundMemoryBudget({ + maxBufferedBytes: 1_000, + maxQueuedBytes: 150 + }) + const ctx = setup({ outboundMemoryBudget }) + ctx.ws.bufferedAmount = 1_001 + + ctx.channel.onMessage((_plaintext, _encryptedReply, encryptedBinaryReply) => { + encryptedBinaryReply(new Uint8Array(100)) + encryptedBinaryReply(new Uint8Array(100)) + }) + ctx.channel.handleRawMessage(encrypt('{"id":"x","method":"terminal.subscribe"}', ctx.sharedKey)) + + expect(ctx.onError).toHaveBeenCalledWith(1013, 'Outbound reply buffer overflow') + expect(trackMock).toHaveBeenCalledWith('remote_outbound_budget_close', { emitter: 'queue' }) + }) + + it('refuses binary once the socket has left OPEN', () => { + const ctx = setup() + ctx.ws.readyState = 3 + let accepted: RpcBinarySendResult = undefined + + ctx.channel.onMessage((_plaintext, _encryptedReply, encryptedBinaryReply) => { + accepted = encryptedBinaryReply(new Uint8Array([1])) + }) + ctx.channel.handleRawMessage(encrypt('{"id":"x","method":"terminal.subscribe"}', ctx.sharedKey)) + + expect(accepted).toBe(false) + }) + + it('drops a backlogged lossy binary reply instead of queueing it, then sends once drained', () => { + const ctx = setup() + const baseline = ctx.ws.sent.length + ctx.ws.bufferedAmount = 9 * 1024 * 1024 + const accepted: RpcBinarySendResult[] = [] + ctx.channel.onMessage((_plaintext, _encryptedReply, encryptedBinaryReply) => { + accepted.push(encryptedBinaryReply(Buffer.from('frame'), { dropWhenBacklogged: true })) + }) + const request = encrypt('{"id":"x","method":"browser.screencast"}', ctx.sharedKey) + ctx.channel.handleRawMessage(request) + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + expect(ctx.ws.sent.length).toBe(baseline) + + ctx.channel.handleRawMessage(request) + expect(accepted).toEqual(['backlogged', true]) + expect(ctx.ws.sent.slice(baseline).map((frame) => decodeSent(ctx, frame))).toEqual([ + 'binary:frame' + ]) + expect(ctx.onError).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts index b26b057abafd..ddb4859a2d78 100644 --- a/src/main/runtime/rpc/e2ee-channel-v2.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -12,6 +12,7 @@ import { sealMobileE2EEV2Frame } from '../../../shared/mobile-e2ee-v2-framing' import { deriveSharedKey } from './e2ee-crypto' +import type { RpcBinarySendResult } from './rpc-binary-sender' import { E2EEChannel } from './e2ee-channel' import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' @@ -276,4 +277,25 @@ describe('E2EEChannel v2', () => { ) expect(ctx.ws.sent[3]!.options).toEqual({ binary: true }) }) + + it('drops a backlogged lossy binary reply without spending a counter, then sends once drained', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + authenticate(ctx, schedule) + ctx.ws.bufferedAmount = 9 * 1024 * 1024 + const accepted: RpcBinarySendResult[] = [] + ctx.channel.onMessage((_request, _textReply, binaryReply) => { + accepted.push(binaryReply(new Uint8Array([1]), { dropWhenBacklogged: true })) + }) + ctx.channel.handleRawMessage(clientText('{"method":"browser.screencast"}', schedule, 1n)) + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + expect(ctx.ws.sent).toHaveLength(2) + + ctx.channel.handleRawMessage(clientText('{"method":"browser.screencast"}', schedule, 2n)) + expect(accepted).toEqual(['backlogged', true]) + expect(openServerFrame(ctx.ws.sent[2]!.data, 'binary', schedule, 1n)).toEqual( + new Uint8Array([1]) + ) + }) }) diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index f4a09397d9a3..5f61c4085a9e 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type { WebSocket } from 'ws' +import type { RpcBinarySendResult } from './rpc-binary-sender' import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto' import { @@ -308,7 +309,7 @@ describe('E2EEChannel', () => { const ctx = setup() const sharedKey = doHandshake(ctx) const sentBefore = ctx.ws.sent.length - let accepted: boolean | void = undefined + let accepted: RpcBinarySendResult = undefined ctx.channel.onMessage((_plaintext, _encryptedReply, encryptedBinaryReply) => { accepted = encryptedBinaryReply( diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index a5ecbbdde334..9d70eeae5ae6 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -1,6 +1,6 @@ // Why: this channel keeps E2EE framing out of RPC handlers, which consume plaintext across transports. import type { WebSocket } from 'ws' -import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto' +import { deriveSharedKey, encrypt, decrypt, decryptBytes } from './e2ee-crypto' import { DesktopMobileE2EEV2Session, type DesktopMobileE2EEV2Context @@ -17,6 +17,11 @@ import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-reque import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner' import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' +import type { + RpcBinarySendOptions, + RpcBinarySendResult, + RpcBinarySender +} from './rpc-binary-sender' import type { RuntimeCapability } from '../../../shared/protocol-version' import type { EventProps } from '../../../shared/telemetry-events' import { track } from '../../telemetry/client' @@ -61,7 +66,7 @@ export class E2EEChannel { | (( plaintext: string, encryptedReply: (response: string) => void, - encryptedBinaryReply: (response: Uint8Array) => boolean | void + encryptedBinaryReply: RpcBinarySender ) => void) | null = null private binaryMessageHandler: ((plaintext: Uint8Array) => void) | null = null @@ -78,7 +83,12 @@ export class E2EEChannel { this.onError = options.onError this.transportContext = options.transportContext ?? { transport: 'direct' } this.requireV2 = options.requireV2 ?? false - this.outbound = new MobileE2EEDesktopOutboundOwner(ws, options.outboundMemoryBudget) + this.outbound = new MobileE2EEDesktopOutboundOwner( + ws, + () => Boolean(this.sharedKey), + () => this.closeForOutboundBudget('queue'), + options.outboundMemoryBudget + ) this.handshakeTimer = setTimeout(() => { this.onError(4002, 'E2EE handshake timeout') @@ -89,7 +99,7 @@ export class E2EEChannel { handler: ( plaintext: string, encryptedReply: (response: string) => void, - encryptedBinaryReply: (response: Uint8Array) => boolean | void + encryptedBinaryReply: RpcBinarySender ) => void ): void { this.messageHandler = handler @@ -154,13 +164,9 @@ export class E2EEChannel { this.closeForOutboundBudget('size') return } - this.outbound.enqueueLegacyText( - encrypt(response, this.sharedKey), - () => Boolean(this.sharedKey), - () => this.closeForOutboundBudget('queue') - ) + this.outbound.enqueueLegacyText(encrypt(response, this.sharedKey)) } - const encryptedBinaryReply = (response: Uint8Array): boolean => { + const encryptedBinaryReply: RpcBinarySender = (response, options) => { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { return false } @@ -168,11 +174,7 @@ export class E2EEChannel { this.closeForOutboundBudget('size') return false } - if (!this.outbound.canSend(response.byteLength + 40)) { - return false - } - this.ws.send(Buffer.from(encryptBytes(response, this.sharedKey)), { binary: true }) - return true + return this.outbound.enqueueLegacyBinary(response, this.sharedKey, options) } this.messageHandler?.(plaintext, encryptedReply, encryptedBinaryReply) } @@ -291,13 +293,13 @@ export class E2EEChannel { this.messageHandler?.( plaintext, (response) => this.enqueueV2({ kind: 'text', plaintext: response }), - (response) => this.enqueueV2({ kind: 'binary', plaintext: response }) + (response, options) => this.enqueueV2({ kind: 'binary', plaintext: response }, options) ), onProtocolError: () => this.onError(4001, 'Invalid binary message before authentication') }) } - private enqueueV2(item: V2OutboundItem): boolean { + private enqueueV2(item: V2OutboundItem, options?: RpcBinarySendOptions): RpcBinarySendResult { if (!this.v2Session || this.ws.readyState !== this.ws.OPEN) { return false } @@ -305,7 +307,7 @@ export class E2EEChannel { this.closeForOutboundBudget('size') return false } - return this.outbound.enqueueV2(item, this.v2Session, () => this.closeForOutboundBudget('queue')) + return this.outbound.enqueueV2(item, this.v2Session, options) } // Why: this close kills the whole remote session. `size` means a producer emitted something @@ -324,7 +326,7 @@ export class E2EEChannel { this.enqueueV2({ kind: 'text', plaintext: JSON.stringify(message) }) } else if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { const frame = encrypt(JSON.stringify(message), this.sharedKey) - this.outbound.sendLegacyFrame(frame, () => this.closeForOutboundBudget('queue')) + this.outbound.sendLegacyFrame(frame) } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts index 09682e10c08e..dab3f8beaabf 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -13,6 +13,7 @@ import { import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' import type { TerminalStreamInputOutcome } from './terminal-input-delivery' import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { RpcBinarySendResult } from '../../rpc-binary-sender' export function installMultiplexFrameDelivery(state: TerminalMultiplexConnection): void { const { sendBinary, emit } = state @@ -30,7 +31,7 @@ export function installMultiplexFrameDelivery(state: TerminalMultiplexConnection // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame state.cursor, or it poisons the client's frame-drop tracker. const resolvedSeq = typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++ - let sent: boolean | void + let sent: RpcBinarySendResult try { sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload })) } catch { diff --git a/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts b/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts index 4eb6157bbd9b..ee076381f438 100644 --- a/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts +++ b/src/main/runtime/rpc/mobile-e2ee-desktop-outbound-owner.ts @@ -1,8 +1,10 @@ import type { WebSocket } from 'ws' import type { WsOutboundBackpressureQueue } from '../../../shared/ws-outbound-backpressure-queue' -import { createLegacyMobileE2EETextReplyQueue } from './mobile-e2ee-outbound-admission' +import { createLegacyMobileE2EEReplyQueue } from './mobile-e2ee-outbound-admission' +import { encryptBytes } from './e2ee-crypto' import { createDesktopMobileE2EEV2OutboundQueue, + desktopMobileE2EEV2OutboundItemBytes, type DesktopMobileE2EEV2OutboundItem } from './mobile-e2ee-v2-desktop-outbound' import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session' @@ -11,56 +13,61 @@ import { type MobileE2EEOutboundMemoryBudget, type MobileE2EEOutboundSocketMemory } from './mobile-e2ee-outbound-memory-budget' +import type { RpcBinarySendOptions } from './rpc-binary-sender' export class MobileE2EEDesktopOutboundOwner { private readonly memoryBudget: MobileE2EEOutboundMemoryBudget private readonly socketMemory: MobileE2EEOutboundSocketMemory | null - private legacyQueue: WsOutboundBackpressureQueue | null = null + // Why: one queue for text and binary, so a binary frame can neither overtake parked text nor drop silently. + private legacyQueue: WsOutboundBackpressureQueue | null = null private v2Queue: WsOutboundBackpressureQueue | null = null constructor( private readonly ws: WebSocket, + private readonly isLegacyKeyed: () => boolean, + private readonly onOverflow: () => void, memoryBudget: MobileE2EEOutboundMemoryBudget = createMobileE2EEOutboundMemoryBudget() ) { this.memoryBudget = memoryBudget this.socketMemory = memoryBudget.registerBufferedAmount(() => ws.bufferedAmount) } - canSend(bytes: number): boolean { - return this.socketMemory?.canSend(bytes) === true - } - - sendLegacyFrame(frame: string, onOverflow: () => void): boolean { - if (!this.canSend(frame.length) || this.ws.readyState !== this.ws.OPEN) { - onOverflow() + sendLegacyFrame(frame: string): boolean { + if (this.socketMemory?.canSend(frame.length) !== true || this.ws.readyState !== this.ws.OPEN) { + this.onOverflow() return false } this.ws.send(frame) return true } - enqueueLegacyText(frame: string, isKeyed: () => boolean, onOverflow: () => void): boolean { - if (!this.socketMemory) { - onOverflow() + enqueueLegacyText(frame: string): boolean { + return this.legacy()?.enqueue(frame) ?? false + } + + enqueueLegacyBinary( + bytes: Uint8Array, + sharedKey: Uint8Array, + options?: RpcBinarySendOptions + ): boolean | 'backlogged' { + const queue = this.legacy() + if (!queue) { return false } - this.legacyQueue ??= createLegacyMobileE2EETextReplyQueue({ - ws: this.ws, - isKeyed, - memoryBudget: this.memoryBudget, - socketMemory: this.socketMemory, - onOverflow - }) - return this.legacyQueue.enqueue(frame) + // Why: idleness is judged on the sealed size (+40) before sealing, so a skipped frame costs no encryption. + if (options?.dropWhenBacklogged && !queue.isIdle(bytes.byteLength + 40)) { + return 'backlogged' + } + return queue.enqueue(Buffer.from(encryptBytes(bytes, sharedKey))) } enqueueV2( item: DesktopMobileE2EEV2OutboundItem, session: DesktopMobileE2EEV2Session, - onOverflow: () => void - ): boolean { + options?: RpcBinarySendOptions + ): boolean | 'backlogged' { if (!this.socketMemory) { - onOverflow() + this.onOverflow() return false } this.v2Queue ??= createDesktopMobileE2EEV2OutboundQueue({ @@ -68,11 +75,32 @@ export class MobileE2EEDesktopOutboundOwner { session, memoryBudget: this.memoryBudget, socketMemory: this.socketMemory, - onOverflow + onOverflow: this.onOverflow }) + if ( + options?.dropWhenBacklogged && + !this.v2Queue.isIdle(desktopMobileE2EEV2OutboundItemBytes(item)) + ) { + return 'backlogged' + } return this.v2Queue.enqueue(item) } + private legacy(): WsOutboundBackpressureQueue | null { + if (!this.socketMemory) { + this.onOverflow() + return null + } + this.legacyQueue ??= createLegacyMobileE2EEReplyQueue({ + ws: this.ws, + isKeyed: this.isLegacyKeyed, + memoryBudget: this.memoryBudget, + socketMemory: this.socketMemory, + onOverflow: this.onOverflow + }) + return this.legacyQueue + } + dispose(): void { this.legacyQueue?.dispose() this.legacyQueue = null diff --git a/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts b/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts index f759f6f5c47b..23bc58e2b4de 100644 --- a/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts +++ b/src/main/runtime/rpc/mobile-e2ee-outbound-admission.ts @@ -41,17 +41,18 @@ export function isMobileE2EEOutboundItemWithinLimit( : isMobileE2EEBinaryPayloadWithinLimit(item.plaintext) } -export function createLegacyMobileE2EETextReplyQueue(args: { +export function createLegacyMobileE2EEReplyQueue(args: { ws: WebSocket isKeyed: () => boolean onOverflow: () => void memoryBudget: MobileE2EEOutboundMemoryBudget socketMemory: MobileE2EEOutboundSocketMemory -}): WsOutboundBackpressureQueue { - return createWsOutboundBackpressureQueue({ - send: (frame) => args.ws.send(frame), - // Encrypted replies are base64 ASCII strings, so length === byte count. - byteLengthOf: (frame) => frame.length, +}): WsOutboundBackpressureQueue { + return createWsOutboundBackpressureQueue({ + send: (frame) => + typeof frame === 'string' ? args.ws.send(frame) : args.ws.send(frame, { binary: true }), + // Encrypted text replies are base64 ASCII strings, so length === byte count. + byteLengthOf: (frame) => (typeof frame === 'string' ? frame.length : frame.byteLength), getBufferedAmount: () => args.ws.bufferedAmount, isWritable: () => args.isKeyed() && args.ws.readyState === args.ws.OPEN, canSend: (bytes) => args.socketMemory.canSend(bytes), diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts index 9bf581037f2a..e0c3068e3f8d 100644 --- a/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts +++ b/src/main/runtime/rpc/mobile-e2ee-v2-desktop-outbound.ts @@ -17,6 +17,16 @@ export type DesktopMobileE2EEV2OutboundItem = | { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } +export function desktopMobileE2EEV2OutboundItemBytes( + item: DesktopMobileE2EEV2OutboundItem +): number { + const bytes = + item.kind === 'text' + ? mobileE2EETextPayloadAdmissionBytes(item.plaintext) + : mobileE2EEBinaryPayloadAdmissionBytes(item.plaintext) + return Number.isFinite(bytes) ? bytes + 82 : bytes +} + export function createDesktopMobileE2EEV2OutboundQueue(args: { ws: WebSocket session: DesktopMobileE2EEV2Session @@ -34,13 +44,7 @@ export function createDesktopMobileE2EEV2OutboundQueue(args: { args.ws.send(Buffer.from(args.session.sealBinary(item.plaintext)), { binary: true }) } }, - byteLengthOf: (item) => { - const bytes = - item.kind === 'text' - ? mobileE2EETextPayloadAdmissionBytes(item.plaintext) - : mobileE2EEBinaryPayloadAdmissionBytes(item.plaintext) - return Number.isFinite(bytes) ? bytes + 82 : bytes - }, + byteLengthOf: desktopMobileE2EEV2OutboundItemBytes, getBufferedAmount: () => args.ws.bufferedAmount, isWritable: () => args.ws.readyState === args.ws.OPEN, canSend: (bytes) => args.socketMemory.canSend(bytes), diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 2d536b15038a..66cda2b0c355 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -5,6 +5,7 @@ import type { E2EEKeypair } from '../e2ee-keypair' import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' import { createMobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { RpcBinarySender } from './rpc-binary-sender' type MobileSocketPayload = string | Uint8Array @@ -48,7 +49,7 @@ type MobileSocketWiringOptions = { socket: AuthenticatedMobileSocket, plaintext: string, reply: (response: string) => void, - sendBinary: (response: Uint8Array) => boolean | void + sendBinary: RpcBinarySender ) => void onBinary: (socket: AuthenticatedMobileSocket, bytes: Uint8Array) => void onClose: (socket: AuthenticatedMobileSocket | null, hasOtherConnections: boolean) => void diff --git a/src/main/runtime/rpc/rpc-binary-sender.ts b/src/main/runtime/rpc/rpc-binary-sender.ts new file mode 100644 index 000000000000..2a56ac5f28af --- /dev/null +++ b/src/main/runtime/rpc/rpc-binary-sender.ts @@ -0,0 +1,12 @@ +export type RpcBinarySendOptions = { + // Why: a lossy stream resends its newest frame later, so queueing stale frames behind a backlog only delays it. + dropWhenBacklogged?: boolean +} + +/** False: not sent or queued. 'backlogged': skipped under dropWhenBacklogged; the socket is alive. */ +export type RpcBinarySendResult = boolean | void | 'backlogged' + +export type RpcBinarySender = ( + bytes: Uint8Array, + options?: RpcBinarySendOptions +) => RpcBinarySendResult diff --git a/src/main/runtime/runtime-browser-commands-browser-click.ts b/src/main/runtime/runtime-browser-commands-browser-click.ts index 49fe9b7900c4..619fce3aed08 100644 --- a/src/main/runtime/runtime-browser-commands-browser-click.ts +++ b/src/main/runtime/runtime-browser-commands-browser-click.ts @@ -20,6 +20,7 @@ import type { BrowserTypeResult } from '../../shared/runtime-types' import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' +import { cancelScreencastSubscriberFrameRetry } from './browser-screencast-subscriber-frame-delivery' export class RuntimeBrowserCommandsWithBrowserClick extends RuntimeBrowserCommandsWithActiveScreencastsByPageId { async browserClick( @@ -152,6 +153,7 @@ export class RuntimeBrowserCommandsWithBrowserClick extends RuntimeBrowserComman return } active.subscribers.delete(subscriptionId) + cancelScreencastSubscriberFrameRetry(subscriber) subscriber.resolveDone() if (active.viewportOwnerSubscriptionId === subscriptionId) { const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) => diff --git a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts index 2e6a447b4f7b..c85f6ddc176e 100644 --- a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts +++ b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts @@ -18,6 +18,7 @@ import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry import type { BrowserWindow } from 'electron' import type { BrowserBackend } from '../browser/browser-backend' import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' export type BrowserCommandTargetParams = { worktree?: string @@ -58,13 +59,14 @@ export type BrowserScreencastStartResult = { } export type ActiveBrowserScreencastSubscriber = { - sendBinary: (bytes: Uint8Array) => boolean | void + sendBinary: RpcBinarySender emit?: (event: BrowserScreencastResult) => void done: Promise resolveDone: () => void viewport: BrowserScreencastViewport budget: BrowserScreencastFrameBudget pendingFrame: Uint8Array | null + pendingFrameRetry: ReturnType | null // Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new // socket never reuses the old id, so a reconnecting device would stack a second subscription. pairedDeviceId?: string diff --git a/src/main/runtime/runtime-browser-commands-browser-screencast.ts b/src/main/runtime/runtime-browser-commands-browser-screencast.ts index 2103f37c326e..48a3ba234967 100644 --- a/src/main/runtime/runtime-browser-commands-browser-screencast.ts +++ b/src/main/runtime/runtime-browser-commands-browser-screencast.ts @@ -18,19 +18,22 @@ import { BrowserError } from '../browser/browser-error' import { randomUUID } from 'node:crypto' import { startBrowserScreencast } from '../browser/browser-screencast-stream' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' -import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' +import { + cancelScreencastSubscriberFrameRetry, + deliverScreencastSubscriberFrame +} from './browser-screencast-subscriber-frame-delivery' import { INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY, - recordScreencastSubscriberSend, screencastSubscriberIsGhost } from './browser-screencast-ghost-subscriber-eviction' import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserCommandsWithBrowserClick { async browserScreencast( params: BrowserScreencastParams, stream: { - sendBinary: (bytes: Uint8Array) => boolean | void + sendBinary: RpcBinarySender emit?: (event: BrowserScreencastResult) => void pairedDeviceId?: string } @@ -82,11 +85,8 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC onFrame: (bytes) => { const ghosts: string[] = [] for (const [subscriptionId, subscriber] of record.subscribers) { - // A slow viewer drops this frame without stalling every other viewer, but the - // newest refusal is retained so a gate that opens later can still be filled. - const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) - subscriber.pendingFrame = delivered ? null : bytes - subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) + // A slow viewer drops this frame without stalling every other viewer. + deliverScreencastSubscriberFrame(subscriber, bytes) if (screencastSubscriberIsGhost(subscriber.delivery)) { ghosts.push(subscriptionId) } @@ -131,6 +131,7 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC this.activeScreencastsByPageId.delete(browserPageId) } for (const subscriber of record.subscribers.values()) { + cancelScreencastSubscriberFrameRetry(subscriber) subscriber.resolveDone() } record.subscribers.clear() @@ -145,6 +146,7 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC viewport, budget, pendingFrame: null, + pendingFrameRetry: null, pairedDeviceId: stream.pairedDeviceId, delivery: INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY }) @@ -180,17 +182,16 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC } return { subscriptionId, + // Why: the frame the pre-ready gate refused goes out with ready, not a retry tick later. flushPendingFrame: () => { const subscriber = active.subscribers.get(subscriptionId) const bytes = subscriber?.pendingFrame if (!subscriber || !bytes) { return } - const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) - subscriber.pendingFrame = delivered ? null : bytes // The replay is this subscriber's first chance to reach its socket, so it is also where // an eviction-eligible delivery history starts. - subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) + deliverScreencastSubscriberFrame(subscriber, bytes) }, session: { done: subscriberDone, diff --git a/src/main/runtime/runtime-browser-screencast-controller.ts b/src/main/runtime/runtime-browser-screencast-controller.ts index 7e3eec68f8f0..05c265cf9ddd 100644 --- a/src/main/runtime/runtime-browser-screencast-controller.ts +++ b/src/main/runtime/runtime-browser-screencast-controller.ts @@ -6,6 +6,7 @@ import { type BrowserScreencastSubscriber } from './browser-screencast-driver-scope' import type { RuntimeBrowserCommands } from './orca-runtime-browser' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' type RuntimeBrowserScreencastControllerDeps = { getCommands: () => RuntimeBrowserCommands @@ -61,7 +62,7 @@ export class RuntimeBrowserScreencastController { connectionId?: string pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' - sendBinary?: (bytes: Uint8Array) => boolean | void + sendBinary?: RpcBinarySender signal?: AbortSignal emit: (result: BrowserScreencastResult) => void } @@ -114,11 +115,11 @@ export class RuntimeBrowserScreencastController { end(emitEnd) } const abortScreencast = (): void => cancel() - const sendBinaryAfterReady = (bytes: Uint8Array): boolean | void => { + const sendBinaryAfterReady: RpcBinarySender = (bytes, sendOptions) => { if (!readyEmitted) { return false } - return options.sendBinary?.(bytes) + return options.sendBinary?.(bytes, sendOptions) } // Why: rotation can happen before ready, so replacements are connection-scoped immediately. diff --git a/src/main/runtime/runtime-edge-command-controller.ts b/src/main/runtime/runtime-edge-command-controller.ts index 12b5cbbec20b..031af2452af3 100644 --- a/src/main/runtime/runtime-edge-command-controller.ts +++ b/src/main/runtime/runtime-edge-command-controller.ts @@ -4,6 +4,7 @@ import { RuntimeEmulatorCommands } from './orca-runtime-emulator' import { RuntimeBrowserScreencastController } from './runtime-browser-screencast-controller' import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory' import { RuntimeJiraCommands } from './runtime-jira-commands' +import type { RpcBinarySender } from './rpc/rpc-binary-sender' type PublicMethods = Pick type BrowserSurface = Omit, 'browserScreencast'> & { @@ -13,7 +14,7 @@ type BrowserSurface = Omit, 'browserScreen connectionId?: string pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' - sendBinary?: (bytes: Uint8Array) => boolean | void + sendBinary?: RpcBinarySender signal?: AbortSignal emit: (result: BrowserScreencastResult) => void } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index e761255222c2..c3809ab190af 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -10,6 +10,7 @@ import type { WebSocketTransport } from '../rpc/ws-transport' import type { DeviceScope } from '../device-registry' import { RuntimeRpcMobileDevices } from './runtime-rpc-mobile-devices' import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' +import type { RpcBinarySender } from '../rpc/rpc-binary-sender' import type { RpcCallerScope } from '../rpc/rpc-caller-scope' import { limitRuntimeRpcReplySize } from './runtime-rpc-reply-size-limit' @@ -32,7 +33,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { protected async handleWebSocketMessage( rawMessage: string, reply: (response: string) => void, - sendBinary: (response: Uint8Array) => boolean | void, + sendBinary: RpcBinarySender, wsTransport?: WebSocketTransport, ws?: WebSocket, authenticatedDeviceToken?: string | null, diff --git a/src/main/runtime/single-page-browser-commands-host-test-double.ts b/src/main/runtime/single-page-browser-commands-host-test-double.ts index 44153c2ad46b..b9a63cf7f774 100644 --- a/src/main/runtime/single-page-browser-commands-host-test-double.ts +++ b/src/main/runtime/single-page-browser-commands-host-test-double.ts @@ -9,6 +9,15 @@ export type AuthoritativeWindowDouble = { webContents: RendererPublicationThrottleTarget } +// A guest the page stream keeps painting while it streams. +export function createStreamedGuest(): RendererPublicationThrottleTarget { + return { + isDestroyed: () => false, + setBackgroundThrottling: vi.fn(), + capturePage: vi.fn(async () => null) + } +} + /** A browser command host whose worktree `wt-1` has one registered page, `page-1`. */ export function createSinglePageBrowserCommandsHost( window: AuthoritativeWindowDouble | null = null diff --git a/src/shared/ws-outbound-backpressure-queue.ts b/src/shared/ws-outbound-backpressure-queue.ts index 0c0667e307f5..059ce507861b 100644 --- a/src/shared/ws-outbound-backpressure-queue.ts +++ b/src/shared/ws-outbound-backpressure-queue.ts @@ -62,6 +62,8 @@ export type WsOutboundBackpressureQueue = { enqueue: (frame: TFrame) => boolean /** Queue-or-send a frame and allow its owner to cancel it before wire delivery. */ enqueueCancelable: (frame: TFrame) => WsOutboundEnqueueResult + /** True when nothing is parked and the wire has room, so enqueue would send this size directly. */ + isIdle: (frameBytes: number) => boolean /** Bytes currently held (not yet handed to the wire). */ queuedBytes: () => number evidence: () => { queuedBytes: number; queuedFrames: number; storageSlots: number } @@ -257,6 +259,12 @@ export function createWsOutboundBackpressureQueue( } } + const isIdle = (bytes: number): boolean => + queuedFrames === 0 && + options.isWritable() && + bufferedAmount() <= softCapBytes && + (options.canSend?.(bytes, false) ?? true) + const enqueueCancelable = (frame: TFrame): WsOutboundEnqueueResult => { if (disposed || overflowed) { return { accepted: false, queued: false, cancel: () => false } @@ -267,12 +275,7 @@ export function createWsOutboundBackpressureQueue( return { accepted: false, queued: false, cancel: () => false } } // Fast path: nothing parked and the wire is under the cap — send directly. - if ( - queuedFrames === 0 && - options.isWritable() && - bufferedAmount() <= softCapBytes && - (options.canSend?.(bytes, false) ?? true) - ) { + if (isIdle(bytes)) { return { accepted: sendFrame(frame), queued: false, @@ -312,6 +315,7 @@ export function createWsOutboundBackpressureQueue( return enqueueCancelable(frame).accepted }, enqueueCancelable, + isIdle, queuedBytes: () => queued, evidence: () => ({ queuedBytes: queued, From b197bc611f90b7e87623a4ed73dd90b8f545cf85 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:58:28 -0400 Subject: [PATCH 10/58] fix(remote): move tabs into a split-off group on the server too (#26522) In a server workspace the desktop and the server mint different ids for a split-off group, so a drag into it named a group the server didn't know and was silently dropped. The desktop now names the server group that holds another tab of the target group, falling back to its own id as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../web-runtime-session-tab-move.test.ts | 103 +++++++++++++++++- .../runtime/web-runtime-session-tab-move.ts | 34 +++++- ...ssion-tabs-sync-host-group-mapping.test.ts | 82 ++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 5 +- .../apply-preparation-unified.ts | 3 +- .../web-session-tabs-sync/layout-groups.ts | 19 ++-- .../runtime/web-session-tabs-sync/state.ts | 2 + .../tracking-mappings.ts | 19 +++- 8 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-host-group-mapping.test.ts diff --git a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts index 15ae9bfb1d82..44840c9737a4 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts @@ -19,6 +19,9 @@ const mocks = vi.hoisted(() => ({ getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), + resolveHostSessionGroupIdForWebSessionTab: vi.fn( + (_args: { tabId: string }): string | null => null + ), trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), seedNativeChatLaunchDraftForAgentTab: vi.fn(), @@ -44,7 +47,8 @@ vi.mock('./web-session-tabs-sync', () => ({ // The production caller invokes the returned settle receipt. return () => {} }, - resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab + resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab, + resolveHostSessionGroupIdForWebSessionTab: mocks.resolveHostSessionGroupIdForWebSessionTab })) vi.mock('@/lib/feature-education-telemetry', () => ({ @@ -234,6 +238,103 @@ describe('moveWebRuntimeSessionTab', () => { }) }) + it('names a group this desktop split off by the host group its other tabs sit in', async () => { + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + setActiveWorktree: mocks.setActiveWorktree, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'desktop-split-group', + activeTabId: 'local-terminal-unified', + tabOrder: ['local-only-unified', 'local-terminal-unified', 'local-browser-unified'] + } + ] + } + }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null + ) + mocks.resolveHostSessionGroupIdForWebSessionTab.mockImplementation((args: { tabId: string }) => + args.tabId === 'local-terminal-unified' + ? 'host-split-group' + : args.tabId === 'local-browser-unified' + ? 'host-source-group' + : null + ) + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + moveWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + targetGroupId: 'desktop-split-group', + kind: 'move-to-group', + index: 2 + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ targetGroupId: 'host-split-group' }) + }) + ) + }) + + it('falls back to the desktop id while a just-split group still records its old server group', async () => { + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + setActiveWorktree: mocks.setActiveWorktree, + groupsByWorktree: { + [WORKTREE_ID]: [ + { id: 'desktop-source', activeTabId: 'local-third', tabOrder: ['local-third'] }, + { + id: 'desktop-split-group', + activeTabId: 'local-moved', + tabOrder: ['local-split-off', 'local-moved'] + } + ] + } + }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => `host-${args.tabId}` + ) + // No server list since the split: every tab still records the source group. + mocks.resolveHostSessionGroupIdForWebSessionTab.mockReturnValue('host-source') + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + moveWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-moved', + targetGroupId: 'desktop-split-group', + kind: 'move-to-group', + index: 1 + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ targetGroupId: 'desktop-split-group' }) + }) + ) + }) + it('does not mirror a reorder when the dragged tab is local-only', async () => { mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( (_state, args: { tabId: string }) => diff --git a/src/renderer/src/runtime/web-runtime-session-tab-move.ts b/src/renderer/src/runtime/web-runtime-session-tab-move.ts index d928873ce032..d80271e2827e 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-move.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-move.ts @@ -45,8 +45,33 @@ export async function moveWebRuntimeSessionTab( } try { - const { resolveHostSessionTabIdForWebSessionTab } = await import('./web-session-tabs-sync') + const { resolveHostSessionGroupIdForWebSessionTab, resolveHostSessionTabIdForWebSessionTab } = + await import('./web-session-tabs-sync') const state = useAppStore.getState() + const groups = state.groupsByWorktree?.[args.worktreeId] ?? [] + const targetGroup = groups.find((group) => group.id === args.targetGroupId) + const recordedHostGroupId = (tabId: string): string | null => + resolveHostSessionGroupIdForWebSessionTab({ + environmentId, + worktreeId: args.worktreeId, + tabId + }) + // Why: until the next server list, a split-off tab still records its source group, as tabs outside do. + const hostGroupIdsOutsideTarget = new Set( + groups + .filter((group) => group.id !== args.targetGroupId) + .flatMap((group) => group.tabOrder) + .filter((tabId) => tabId !== args.tabId) + .map(recordedHostGroupId) + ) + // Why: a group this desktop split off has another id on the host, so name it by a tab it holds. + const targetHostGroupId = + (targetGroup?.tabOrder ?? []) + .filter((tabId) => tabId !== args.tabId) + .map(recordedHostGroupId) + .find((groupId) => groupId !== null && !hostGroupIdsOutsideTarget.has(groupId)) ?? + // No server-known tab in the target group: the desktop id, unknown to the server, as before. + args.targetGroupId const resolveHostBackedTabId = (tabId: string): string | null => resolveHostSessionTabIdForWebSessionTab(state, { environmentId, @@ -72,9 +97,8 @@ export async function moveWebRuntimeSessionTab( } const targetHostIndex = args.kind === 'move-to-group' && typeof args.index === 'number' - ? (state.groupsByWorktree?.[args.worktreeId] - ?.find((group) => group.id === args.targetGroupId) - ?.tabOrder.slice(0, args.index) + ? (targetGroup?.tabOrder + .slice(0, args.index) .map(resolveHostBackedTabId) .filter((tabId): tabId is string => Boolean(tabId)).length ?? args.index) : args.kind === 'move-to-group' @@ -83,7 +107,7 @@ export async function moveWebRuntimeSessionTab( const base = { worktree: toRuntimeWorktreeSelector(args.worktreeId), tabId: movedHostTabId, - targetGroupId: args.targetGroupId + targetGroupId: targetHostGroupId } const move = args.kind === 'reorder' diff --git a/src/renderer/src/runtime/web-session-tabs-sync-host-group-mapping.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-host-group-mapping.test.ts new file mode 100644 index 000000000000..6aeb397662b1 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-host-group-mapping.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { + applyWebSessionTabsSnapshot, + resolveHostSessionGroupIdForWebSessionTab +} from './web-session-tabs-sync' +import { + ENV, + LEAF_ID, + NOW, + SECOND_LEAF_ID, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +function terminal(parentTabId: string, leafId: string, isActive: boolean) { + return { + type: 'terminal' as const, + id: `${parentTabId}::${leafId}`, + parentTabId, + leafId, + title: 'Terminal', + status: 'ready' as const, + terminal: `term_${parentTabId}`, + isActive + } +} + +describe('host group of a mirrored tab', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('names the host group each mirrored tab sits in, as of the last applied snapshot', () => { + const resolve = (hostTabId: string) => + resolveHostSessionGroupIdForWebSessionTab({ + environmentId: ENV, + worktreeId: WT, + tabId: toWebTerminalSurfaceTabId(hostTabId) + }) + const tabs = [ + terminal('host-tab-1', LEAF_ID, true), + terminal('host-tab-2', SECOND_LEAF_ID, false) + ] + applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot(tabs, { + tabGroups: [ + { id: 'host-left', activeTabId: 'host-tab-1', tabOrder: ['host-tab-1'] }, + { id: 'host-right', activeTabId: 'host-tab-2', tabOrder: ['host-tab-2'] } + ] + }), + ENV, + NOW + ) + expect(resolve('host-tab-1')).toBe('host-left') + expect(resolve('host-tab-2')).toBe('host-right') + + applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot(tabs, { + snapshotVersion: 2, + tabGroups: [ + { id: 'host-left', activeTabId: 'host-tab-1', tabOrder: ['host-tab-1', 'host-tab-2'] } + ] + }), + ENV, + NOW + ) + expect(resolve('host-tab-2')).toBe('host-left') + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index ffaf111de41a..ac9ac6d240e2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -22,7 +22,10 @@ export { _getWebSessionTabsReceiptTrackingCountsForTest, _getWebSessionTabsTrackingCountsForTest } from './web-session-tabs-sync/tracking-lifecycle' -export { resolveHostSessionTabIdForWebSessionTab } from './web-session-tabs-sync/tracking-mappings' +export { + resolveHostSessionGroupIdForWebSessionTab, + resolveHostSessionTabIdForWebSessionTab +} from './web-session-tabs-sync/tracking-mappings' export { decideWebSessionTabsSnapshot, shouldApplyWebSessionTabsSnapshot, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts index 888f458c3140..f80b1d9173e8 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts @@ -230,7 +230,8 @@ export function prepareWebSessionTabsSnapshotUnified( terminalTabs: mirroredTerminalTabEntries, browserTabs: mirroredBrowserTabs, editorTabs: mirroredEditorTabs, - agentTabs: mirroredAgentTabs + agentTabs: mirroredAgentTabs, + hostGroupIdByTabId }) return { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts index d85b7c6954aa..dd799d329970 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts @@ -57,24 +57,25 @@ export function updateHostSessionTabIdMappings(args: { browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] agentTabs: readonly MirroredAgentTab[] + hostGroupIdByTabId: ReadonlyMap }): void { clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) + const map = (tabId: string, hostTabId: string): void => + setHostSessionTabIdMapping( + { ...args, tabId }, + hostTabId, + args.hostGroupIdByTabId.get(hostTabId) + ) const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) for (const surface of args.terminalSurfaces) { const localId = toWebTerminalSurfaceTabId(surface.parentTabId) if (mirroredTerminalIds.has(localId)) { - setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) + map(localId, surface.parentTabId) } } - for (const entry of args.browserTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } - for (const entry of args.editorTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } - for (const entry of args.agentTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + for (const entry of [...args.browserTabs, ...args.editorTabs, ...args.agentTabs]) { + map(entry.unifiedTab.id, entry.hostTabId) } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 8719fb03d78b..aa0297c250f3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -151,6 +151,8 @@ export const sessionTabsInventoryOmissionsByWorktree = new Map< SessionTabsInventoryOmissionObservation >() export const hostSessionTabIdByLocalKey = new Map() +// Why: desktop and host mint their own ids for a group a split creates; moves name the host's. +export const hostSessionGroupIdByLocalKey = new Map() export const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< string, Map> diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts index ed6ba998a1e9..1da51045d2ff 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts @@ -1,5 +1,6 @@ import type { WebSessionTabsSyncState } from './state' import { + hostSessionGroupIdByLocalKey, hostSessionTabIdByLocalKey, hostSessionTabMappingKeysByEnvironmentAndWorktree } from './state' @@ -21,6 +22,7 @@ export function clearHostSessionTabIdMappings(environmentId: string, worktreeId: } for (const mappingKey of mappingKeys) { hostSessionTabIdByLocalKey.delete(mappingKey) + hostSessionGroupIdByLocalKey.delete(mappingKey) } mappingKeysByWorktree?.delete(worktreeId) if (mappingKeysByWorktree?.size === 0) { @@ -30,10 +32,16 @@ export function clearHostSessionTabIdMappings(environmentId: string, worktreeId: export function setHostSessionTabIdMapping( args: { environmentId: string; worktreeId: string; tabId: string }, - hostTabId: string + hostTabId: string, + hostGroupId?: string ): void { const mappingKey = hostSessionTabMappingKey(args) hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) + if (hostGroupId) { + hostSessionGroupIdByLocalKey.set(mappingKey, hostGroupId) + } else { + hostSessionGroupIdByLocalKey.delete(mappingKey) + } const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() @@ -57,6 +65,15 @@ export function resolveHostSessionTabIdForWebSessionTab( ) } +/** The host group holding the host tab a local tab mirrors, as of the last applied snapshot. */ +export function resolveHostSessionGroupIdForWebSessionTab(args: { + environmentId: string + worktreeId: string + tabId: string +}): string | null { + return hostSessionGroupIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? null +} + /** Enumerate only this publishing host's workspace mappings. */ export function hostSessionTabIdsByLocalTabForWorktree( environmentId: string, From 29b0aa534e6d6dfe62a36c4d013e16a1471540c7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:03:30 -0400 Subject: [PATCH 11/58] test(relay): load generator can reopen a closed phone splice instead of failing the run (#27379) * test(relay): load generator can reopen a closed phone splice instead of failing the run (--splice-retry-max-seconds) * fix(relay-load): declare a reconnect after an established control, as desktops do Without it the director's sticky lane is never reached, so a cell restart under load cannot exercise the sticky re-booking. * fix(relay-load): close a control after 75 s of silence, as desktops do The load balancer keeps a dead backend's client websocket open, so a stopped cell's simulated hosts never noticed and never re-assigned. --- cloud/dev/scripts/load-relay-controls.mjs | 34 ++++++-- cloud/dev/scripts/relay-load-control-peer.mjs | 25 +++++- .../scripts/relay-load-control-peer.test.mjs | 38 +++++++++ cloud/dev/scripts/relay-load-profile.mjs | 2 + cloud/dev/scripts/relay-load-splice-retry.mjs | 40 +++++++++ .../scripts/relay-load-splice-retry.test.mjs | 81 +++++++++++++++++++ cloud/package.json | 2 +- 7 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 cloud/dev/scripts/relay-load-splice-retry.mjs create mode 100644 cloud/dev/scripts/relay-load-splice-retry.test.mjs diff --git a/cloud/dev/scripts/load-relay-controls.mjs b/cloud/dev/scripts/load-relay-controls.mjs index 59a1714f3ed0..be642bdbeb3e 100644 --- a/cloud/dev/scripts/load-relay-controls.mjs +++ b/cloud/dev/scripts/load-relay-controls.mjs @@ -31,6 +31,7 @@ import { runRelayLoadWithShutdown } from './relay-load-run-lifecycle.mjs' import { createRelayLoadReaderEvidence } from './relay-load-reader-evidence.mjs' +import { holdRelayLoadSplice, relayLoadSpliceReopenSummary } from './relay-load-splice-retry.mjs' import { parseRelayLoadArguments, relayLoadPrincipalIndex, @@ -107,6 +108,9 @@ function report(state, final = false) { peakActiveSplices: state.peakActiveSplices, completedSplices: state.completedSplices, failedSplices: state.failedSplices, + spliceRetries: state.spliceRetries, + spliceFailuresByReason: state.spliceFailuresByReason, + spliceReopen: relayLoadSpliceReopenSummary(state.spliceReopenGapsMs, state.spliceFailedAt.size), slowReaderSplicesCompleted: state.slowReaderSplicesCompleted, wedgedReaderSplicesClosed: state.wedgedReaderSplicesClosed, readerQueueEvidence, @@ -242,6 +246,10 @@ const state = { peakActiveSplices: 0, completedSplices: 0, failedSplices: 0, + spliceRetries: 0, + spliceFailuresByReason: {}, + spliceFailedAt: new Map(), + spliceReopenGapsMs: [], slowReaderSplicesCompleted: 0, wedgedReaderSplicesClosed: 0, readerEvidence: null, @@ -329,6 +337,11 @@ function observe(type, detail) { else if (type === 'protocolError') state.protocolErrors++ else if (type === 'socketError') state.socketErrors++ else if (type === 'spliceOpened') { + const failedAt = state.spliceFailedAt.get(detail.index) + if (failedAt !== undefined) { + state.spliceReopenGapsMs.push(Date.now() - failedAt) + state.spliceFailedAt.delete(detail.index) + } state.activeSplices++ state.peakActiveSplices = Math.max(state.peakActiveSplices, state.activeSplices) } else if (type === 'spliceCompleted') { @@ -559,11 +572,22 @@ await runRelayLoadWithShutdown(async () => { } const splicePromises = spliceIndexes.map((index, spliceIndex) => delay(relayLoadSpliceStartDelayMs(config, spliceIndex)).then(() => - peers.get(index).openSplice({ - payloadBytes: config.splicePayloadBytes, - ...relayLoadSpliceProfile(config, spliceIndex), - observeReaderPressure, - holdMs: config.spliceHoldMs + holdRelayLoadSplice({ + open: () => + peers.get(index).openSplice({ + payloadBytes: config.splicePayloadBytes, + ...relayLoadSpliceProfile(config, spliceIndex), + observeReaderPressure, + holdMs: config.spliceHoldMs + }), + retryMaxMs: config.spliceRetryMaxMs, + stopping: () => state.stopping, + onFailure: (reason) => { + state.spliceRetries++ + state.spliceFailuresByReason[reason] = (state.spliceFailuresByReason[reason] ?? 0) + 1 + if (!state.spliceFailedAt.has(index)) state.spliceFailedAt.set(index, Date.now()) + }, + delay }) ) ) diff --git a/cloud/dev/scripts/relay-load-control-peer.mjs b/cloud/dev/scripts/relay-load-control-peer.mjs index 57b5da37506a..e250c0f65447 100644 --- a/cloud/dev/scripts/relay-load-control-peer.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.mjs @@ -11,6 +11,9 @@ const { buildHostProofMacInput, HOST_CHALLENGE_PLAINTEXT_DOMAIN } = await import requireFromRelay.resolve('@orca-cloud/relay-contract') ) +export const CONTROL_SILENCE_LIMIT_MS = 75_000 +const CONTROL_SILENCE_CHECK_INTERVAL_MS = 15_000 + const REPORTABLE_ASSIGNMENT_ERRORS = [ 'relay_capacity_exhausted', 'relay_connection_headroom_exhausted', @@ -278,6 +281,7 @@ export class RelayLoadControlPeer { this.generation = ack.generation this.controlResumeSecret = ack.controlResumeSecret socket.on('message', (data) => this.onMessage(socket, data)) + this.watchSilence(socket) socket.once('close', (code) => this.onClose(socket, code)) socket.once('error', (error) => this.observe('socketError', { index: this.index, error })) this.observe('connected', { index: this.index }) @@ -741,7 +745,9 @@ export class RelayLoadControlPeer { body: JSON.stringify({ v: 1, relayHostId: this.relayHostId, - ...(preferredRegion ? { preferredRegion } : {}) + ...(preferredRegion ? { preferredRegion } : {}), + // Desktops declare a reconnect after an established control; the director's sticky lane needs it. + ...(this.generation === undefined ? {} : { reconnect: true }) }) }, 'relay assignment timeout', @@ -821,6 +827,23 @@ export class RelayLoadControlPeer { } } + // Desktops close a control after 75 s with no inbound frame (the cell pings every 15 s); the + // load balancer keeps a dead backend's client socket open, so without this a host never re-assigns. + watchSilence(socket) { + let lastInboundAt = Date.now() + const noteInbound = () => { + lastInboundAt = Date.now() + } + socket.on('message', noteInbound) + socket.on('ping', noteInbound) + socket.on('pong', noteInbound) + const timer = setInterval(() => { + if (Date.now() - lastInboundAt > CONTROL_SILENCE_LIMIT_MS) socket.terminate() + }, CONTROL_SILENCE_CHECK_INTERVAL_MS) + timer.unref?.() + socket.once('close', () => clearInterval(timer)) + } + onClose(socket, code) { if (this.socket !== socket) return this.socket = null diff --git a/cloud/dev/scripts/relay-load-control-peer.test.mjs b/cloud/dev/scripts/relay-load-control-peer.test.mjs index 2a41ae16d9cc..0f7fba197281 100644 --- a/cloud/dev/scripts/relay-load-control-peer.test.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.test.mjs @@ -1034,3 +1034,41 @@ test('a twin shares its host keys and connects with a given assignment, not the assert.equal(JSON.parse(socket.sent[0]).assignmentEpoch, 7) assert.equal(fetched.some((url) => url.endsWith('/v1/assign')), false) }) + +test('declares a reconnect only after an established control', async (context) => { + const originalFetch = global.fetch + context.after(() => { + global.fetch = originalFetch + }) + const bodies = [] + global.fetch = async (_url, { body }) => { + bodies.push(JSON.parse(body)) + return response({ cellUrl: 'https://cell.test', assignmentEpoch: 1 }) + } + const peer = new RelayLoadControlPeer(0, peerOptions(), () => undefined) + const token = await peer.relayToken() + await peer.assignment(token) + peer.generation = 1 + await peer.assignment(token) + assert.equal(bodies[0].reconnect, undefined) + assert.equal(bodies[1].reconnect, true) +}) + +test('a silent control is terminated after the desktop silence limit', (context) => { + context.mock.timers.enable({ apis: ['setInterval', 'Date'] }) + const peer = new RelayLoadControlPeer(0, peerOptions(), () => undefined) + const socket = fakeOpenSocket() + let terminated = 0 + socket.terminate = () => { + terminated++ + socket.emit('close', 1006, Buffer.from('')) + } + peer.watchSilence(socket) + context.mock.timers.tick(60_000) + socket.emit('ping') + context.mock.timers.tick(60_000) + assert.equal(terminated, 0) + context.mock.timers.tick(30_000) + context.mock.timers.tick(60_000) + assert.equal(terminated, 1) +}) diff --git a/cloud/dev/scripts/relay-load-profile.mjs b/cloud/dev/scripts/relay-load-profile.mjs index 6a78e9f15db8..076a7407b0f2 100644 --- a/cloud/dev/scripts/relay-load-profile.mjs +++ b/cloud/dev/scripts/relay-load-profile.mjs @@ -307,6 +307,8 @@ export function parseRelayLoadArguments(argv) { rampMs: integer('--ramp-seconds', 60) * 1000, rampStartDelayMs: integer('--ramp-start-delay-ms', 0), reconnectMaxMs: integer('--reconnect-max-seconds', 30) * 1000, + // 0 keeps the old behaviour: the first splice failure fails the run. + spliceRetryMaxMs: integer('--splice-retry-max-seconds', 0) * 1000, shardCount, shardIndex, aggregateControls, diff --git a/cloud/dev/scripts/relay-load-splice-retry.mjs b/cloud/dev/scripts/relay-load-splice-retry.mjs new file mode 100644 index 000000000000..4c4f441d145d --- /dev/null +++ b/cloud/dev/scripts/relay-load-splice-retry.mjs @@ -0,0 +1,40 @@ +// A phone reopens its splice after a close, the way the app does, instead of failing the run. +// Without retries (retryMaxMs 0) the first failure still rejects, as before. +export async function holdRelayLoadSplice({ + open, + retryMaxMs, + stopping, + onFailure = () => undefined, + delay, + random = Math.random +}) { + if (retryMaxMs <= 0) return await open() + while (!stopping()) { + try { + return await open() + } catch (error) { + if (stopping()) return undefined + onFailure(relayLoadSpliceFailureReason(error)) + await delay(Math.ceil(random() * retryMaxMs)) + } + } + return undefined +} + +// Stable buckets for the report: close codes and HTTP statuses, never ids. +export function relayLoadSpliceFailureReason(error) { + const message = error instanceof Error ? error.message : String(error) + const closed = /^splice closed: (\d+)/.exec(message) + if (closed) return `closed_${closed[1]}` + const response = /^Unexpected server response: (\d{3})/.exec(message) + if (response) return `http_${response[1]}` + if (message.startsWith('active control required')) return 'no_active_control' + return 'other' +} + +export function relayLoadSpliceReopenSummary(gapsMs, awaiting) { + const sorted = [...gapsMs].sort((left, right) => left - right) + const at = (quantile) => + sorted.length === 0 ? null : sorted[Math.min(sorted.length - 1, Math.floor(quantile * sorted.length))] + return { reopened: sorted.length, awaiting, p50Ms: at(0.5), p95Ms: at(0.95), maxMs: sorted.at(-1) ?? null } +} diff --git a/cloud/dev/scripts/relay-load-splice-retry.test.mjs b/cloud/dev/scripts/relay-load-splice-retry.test.mjs new file mode 100644 index 000000000000..9da7f963cf2d --- /dev/null +++ b/cloud/dev/scripts/relay-load-splice-retry.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + holdRelayLoadSplice, + relayLoadSpliceFailureReason, + relayLoadSpliceReopenSummary +} from './relay-load-splice-retry.mjs' + +const noDelay = async () => undefined + +test('without retries the first splice failure still fails the run', async () => { + await assert.rejects( + holdRelayLoadSplice({ + open: async () => { throw new Error('splice closed: 1006 ') }, + retryMaxMs: 0, + stopping: () => false, + delay: noDelay + }), + /splice closed: 1006/ + ) +}) + +test('with retries a closed splice reopens and its reasons are counted', async () => { + const failures = [] + const waits = [] + let attempts = 0 + const result = await holdRelayLoadSplice({ + open: async () => { + attempts++ + if (attempts === 1) throw new Error('splice closed: 1006 ') + if (attempts === 2) throw new Error('Unexpected server response: 429') + return 'held' + }, + retryMaxMs: 2_000, + stopping: () => false, + onFailure: (reason) => failures.push(reason), + delay: async (ms) => { waits.push(ms) }, + random: () => 0.5 + }) + assert.equal(result, 'held') + assert.deepEqual(failures, ['closed_1006', 'http_429']) + assert.deepEqual(waits, [1_000, 1_000]) +}) + +test('a failure during shutdown is not counted and does not retry', async () => { + let stopping = false + const failures = [] + const result = await holdRelayLoadSplice({ + open: async () => { stopping = true; throw new Error('splice closed: 1000 ') }, + retryMaxMs: 1_000, + stopping: () => stopping, + onFailure: (reason) => failures.push(reason), + delay: noDelay + }) + assert.equal(result, undefined) + assert.deepEqual(failures, []) +}) + +test('failure reasons bucket close codes, HTTP statuses and a missing control', () => { + assert.equal(relayLoadSpliceFailureReason(new Error('splice closed: 4409 demoted')), 'closed_4409') + assert.equal(relayLoadSpliceFailureReason(new Error('Unexpected server response: 503')), 'http_503') + assert.equal(relayLoadSpliceFailureReason(new Error('active control required for splice')), 'no_active_control') + assert.equal(relayLoadSpliceFailureReason(new Error('relay frame timeout')), 'other') +}) + +test('the reopen summary reports quantiles and splices still waiting', () => { + assert.deepEqual(relayLoadSpliceReopenSummary([300, 100, 200], 1), { + reopened: 3, + awaiting: 1, + p50Ms: 200, + p95Ms: 300, + maxMs: 300 + }) + assert.deepEqual(relayLoadSpliceReopenSummary([], 0), { + reopened: 0, + awaiting: 0, + p50Ms: null, + p95Ms: null, + maxMs: null + }) +}) diff --git a/cloud/package.json b/cloud/package.json index 88eed0a0549a..84c33de4853c 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -22,7 +22,7 @@ "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-reserve-dead-man-alert.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-duplicate-assign.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-duplicate-assign.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-load-splice-retry.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { From 5a0795a59ba6faede7a6749749a81fabc04de4e8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:04:38 -0400 Subject: [PATCH 12/58] fix(browser): double-click in the address bar selects a word, like other browsers (#27371) * fix(browser): double-click in the address bar selects a word, like other browsers The click that focuses the address bar selects the whole URL. Chromium treats a press inside a selection as a possible text drag, so the second press of a double-click never picked a word, and re-clicking a blurred bar restored its old selection instead of selecting all. Clear that selection before the press (focusing first, as Firefox's urlbar does) so single, double and triple clicks match Chrome and Firefox. * refactor(browser): keep address-bar click selection to one flag inside its hook The focusing press now focuses and clears the selection itself, so focus can always select all and the bar no longer shares the press flag with its focus and blur handlers. A second press only meets a range our click made, so event.detail === 2 replaces the select-all flag. --- .../BrowserAddressBar.test.tsx | 36 ++++++++++++++++++ .../assemble-chrome/BrowserAddressBar.tsx | 22 +++-------- .../use-browser-address-bar-click-select.ts | 37 +++++++++++++++++++ 3 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-click-select.ts diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx index 5473a7514e76..b5c0770eb382 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx @@ -248,4 +248,40 @@ describe('BrowserAddressBar autocomplete preview', () => { expect(onNavigate).not.toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled() }) + + it('lets the second press of a double-click select a word instead of the whole URL', async () => { + const url = 'https://github.com/stablyai/orca/pull/27021' + await act(async () => { + root.render() + }) + const input = container.querySelector('input[data-orca-browser-address-bar]')! + + act(() => { + input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, detail: 1 })) + input.setSelectionRange(url.length, url.length) + input.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0, detail: 1 })) + }) + expect([input.selectionStart, input.selectionEnd]).toEqual([0, url.length]) + + act(() => { + input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, detail: 2 })) + }) + // Collapsed, so Chromium's native double-click word selection is not suppressed. + expect(input.selectionStart).toBe(input.selectionEnd) + }) + + it('clears a selection kept from before blur when a press refocuses the bar', async () => { + const url = 'https://github.com/stablyai/orca/pull/27021' + await act(async () => { + root.render() + }) + const input = container.querySelector('input[data-orca-browser-address-bar]')! + input.setSelectionRange(0, url.length) + + act(() => { + input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, detail: 1 })) + }) + // Collapsed, so the press places a caret and the click can expand it to the whole URL. + expect(input.selectionStart).toBe(input.selectionEnd) + }) }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx index 18a75ef85cdc..f3c540a8aa71 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx @@ -9,6 +9,7 @@ import type { BrowserPageDocLocation } from '../../../../../shared/browser-works import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions' import { shouldOverlayBrowserAddressBar } from './browser-address-bar-expansion' import { saveBrowserAddressBarEditSession } from './browser-address-bar-edit-session' +import { useBrowserAddressBarClickSelect } from './use-browser-address-bar-click-select' import { useBrowserAddressBarDismissal } from './use-browser-address-bar-dismissal' import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session' import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList' @@ -53,7 +54,7 @@ export default function BrowserAddressBar({ const browserDefaultSearchEngine = useAppStore((s) => s.browserDefaultSearchEngine) const browserKagiSessionLink = useAppStore((s) => s.browserKagiSessionLink) const closingRef = useRef(false) - const initialMouseDownRef = useRef(false) + const addressBarClickSelect = useBrowserAddressBarClickSelect() const openedAtRef = useRef(0) const blurCloseTimerRef = useRef(null) const closingResetTimerRef = useRef(null) @@ -242,15 +243,12 @@ export default function BrowserAddressBar({ window.clearTimeout(blurCloseTimerRef.current) blurCloseTimerRef.current = null } - if (!initialMouseDownRef.current) { - inputRef.current?.select() - } + inputRef.current?.select() openedAtRef.current = Date.now() setOpen(true) }, [inputRef]) const handleBlur = useCallback(() => { - initialMouseDownRef.current = false // Why: delay close so that clicking a suggestion item registers before // the popover unmounts. Without this, onSelect never fires because the // mousedown on PopoverContent triggers input blur first. @@ -428,18 +426,8 @@ export default function BrowserAddressBar({ ref={inputRef} value={value} onFocus={handleFocus} - onMouseDown={(event) => { - initialMouseDownRef.current = - event.button === 0 && document.activeElement !== event.currentTarget - }} - onClick={(event) => { - const input = event.currentTarget - // Preserve native drag selection; only expand a collapsed initial click. - if (initialMouseDownRef.current && input.selectionStart === input.selectionEnd) { - input.select() - } - initialMouseDownRef.current = false - }} + onMouseDown={addressBarClickSelect.onMouseDown} + onClick={addressBarClickSelect.onClick} onBlur={handleBlur} onKeyDown={handleKeyDown} data-orca-browser-address-bar="true" diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-click-select.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-click-select.ts new file mode 100644 index 000000000000..71f1d9e50cb1 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-click-select.ts @@ -0,0 +1,37 @@ +import { useCallback, useRef, type MouseEvent } from 'react' + +/** + * Browser-style mouse selection for the address bar: the click that focuses it selects the whole + * URL, a double-click selects a word, and a triple-click selects everything again. + */ +export function useBrowserAddressBarClickSelect(): { + onMouseDown: (event: MouseEvent) => void + onClick: (event: MouseEvent) => void +} { + const pressFocusedRef = useRef(false) + + const onMouseDown = useCallback((event: MouseEvent) => { + const input = event.currentTarget + pressFocusedRef.current = event.button === 0 && document.activeElement !== input + // Why: Chromium treats a press inside a selection as a text drag, so it neither places a caret + // nor picks a double-click word; clear the old or click-made selection first. + if (pressFocusedRef.current) { + // Focus first: a press that focuses the bar would restore the selection being cleared. + input.focus() + input.setSelectionRange(0, 0) + } else if (event.detail === 2) { + input.setSelectionRange(0, 0) + } + }, []) + + const onClick = useCallback((event: MouseEvent) => { + const input = event.currentTarget + // Preserve native drag selection; only expand a collapsed focusing click. + if (pressFocusedRef.current && input.selectionStart === input.selectionEnd) { + input.select() + } + pressFocusedRef.current = false + }, []) + + return { onMouseDown, onClick } +} From c19e2dd363e55f211fafb9c2fc82d27d8f437ebb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:05:10 -0400 Subject: [PATCH 13/58] fix(relay-load): reuse an Asia load principal set for shards past the fourth (#27403) Staging auth mints principal sets for shards 0-3, so the second hosted runner's shards 4-7 were refused. Their hosts stay distinct by peer index; each set carries the same host count as the proven 4 x 710 run. --- cloud/dev/scripts/github-smoke-token.mjs | 5 ++++- cloud/dev/scripts/load-relay-controls.mjs | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cloud/dev/scripts/github-smoke-token.mjs b/cloud/dev/scripts/github-smoke-token.mjs index f0bd67c10240..af4c61fd52b9 100644 --- a/cloud/dev/scripts/github-smoke-token.mjs +++ b/cloud/dev/scripts/github-smoke-token.mjs @@ -50,10 +50,13 @@ export async function requestGitHubSmokeTokens( } } +// Staging auth mints Asia load principal sets for this many shards. +export const RELAY_ASIA_LOAD_PRINCIPAL_SHARDS = 4 + function parseRelayAsiaLoadOptions(value) { if ( !value || typeof value !== 'object' || - !Number.isSafeInteger(value.shardIndex) || value.shardIndex < 0 || value.shardIndex > 3 || + !Number.isSafeInteger(value.shardIndex) || value.shardIndex < 0 || value.shardIndex >= RELAY_ASIA_LOAD_PRINCIPAL_SHARDS || !Number.isSafeInteger(value.principalCount) || value.principalCount < 1 || value.principalCount > 32 ) throw new Error('Relay Asia load principal request is invalid') diff --git a/cloud/dev/scripts/load-relay-controls.mjs b/cloud/dev/scripts/load-relay-controls.mjs index be642bdbeb3e..409f83d8f6e2 100644 --- a/cloud/dev/scripts/load-relay-controls.mjs +++ b/cloud/dev/scripts/load-relay-controls.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { monitorEventLoopDelay } from 'node:perf_hooks' import { setTimeout as delay } from 'node:timers/promises' import { RelayLoadControlPeer } from './relay-load-control-peer.mjs' -import { requestGitHubSmokeTokens } from './github-smoke-token.mjs' +import { RELAY_ASIA_LOAD_PRINCIPAL_SHARDS, requestGitHubSmokeTokens } from './github-smoke-token.mjs' import { relayLoadFailureReason } from './relay-load-connection-failure.mjs' import { assertRelayLoadDirectorCapacityToken, @@ -164,7 +164,9 @@ if (!accessToken && !key.signingKey && process.env.ACTIONS_ID_TOKEN_REQUEST_URL const loadOptions = config.relayAsiaLoadPrincipalCount > 0 ? { relayAsiaLoad: { - shardIndex: config.shardIndex, + // Staging auth mints principal sets for shards 0-3 only; shards past that (a second + // hosted runner) reuse a set, and their hosts stay distinct by peer index. + shardIndex: config.shardIndex % RELAY_ASIA_LOAD_PRINCIPAL_SHARDS, principalCount: config.relayAsiaLoadPrincipalCount } } From 38aa54f71691694404c8500ad258b7b88cb4beca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:08:02 -0700 Subject: [PATCH 14/58] fix(doc-preview): serve authorized web fonts as binary bytes (#26849) Co-authored-by: m4air Co-authored-by: noan98 <187374956+noan98@users.noreply.github.com> --- .../browser/doc-preview-file-reader.test.ts | 3 +- src/main/browser/doc-preview-file-reader.ts | 9 ++--- src/shared/doc-preview-file-access.test.ts | 36 +++++++++++++++++++ src/shared/doc-preview-file-access.ts | 2 ++ src/shared/font-file-extensions.ts | 6 ++++ 5 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 src/shared/font-file-extensions.ts diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts index 661c62bd8157..73b6414ebd03 100644 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -107,8 +107,7 @@ describe('readDocPreviewFile — ssh owner', () => { expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) }) - // Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by - // design — the failure must name the file type, not a stale server. + // Older hosts refuse fonts without bytes or a MIME type; keep that answer explicit. it('reports a file type the host will not send as unsupported-asset', async () => { mocks.readDocPreviewFile.mockResolvedValue({ content: '', isBinary: true }) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts index 84a382af859a..3531d5065aa6 100644 --- a/src/main/browser/doc-preview-file-reader.ts +++ b/src/main/browser/doc-preview-file-reader.ts @@ -1,6 +1,7 @@ import { extname } from 'node:path' import type { RuntimeFilePreviewResult } from '../../shared/runtime-file-contracts' import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' +import { FONT_FILE_MIME_TYPES } from '../../shared/font-file-extensions' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { getCanonicalUserDataPath } from '../persistence' @@ -18,8 +19,7 @@ const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 const DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES = 10 * 1024 * 1024 const DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES = 10 * 1024 * 1024 -/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so - * a font is refused there by design, not by version. Name the file type, not the host's age. */ +/** Older hosts may refuse a binary format, so report the file type they cannot send. */ const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' /** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would @@ -64,10 +64,7 @@ const DOC_PREVIEW_CONTENT_TYPES: Record = { '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.pdf': 'application/pdf', - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.ttf': 'font/ttf', - '.otf': 'font/otf' + ...FONT_FILE_MIME_TYPES } export function docPreviewContentType(relativePath: string): string { diff --git a/src/shared/doc-preview-file-access.test.ts b/src/shared/doc-preview-file-access.test.ts index b9b8c2c298e5..5749c7a3b3ab 100644 --- a/src/shared/doc-preview-file-access.test.ts +++ b/src/shared/doc-preview-file-access.test.ts @@ -162,4 +162,40 @@ describe('readAuthorizedDocPreviewFile', () => { 'file_too_large' ) }) + + it.each([ + { extension: '.woff', mimeType: 'font/woff' }, + { extension: '.WOFF2', mimeType: 'font/woff2' }, + { extension: '.ttf', mimeType: 'font/ttf' }, + { extension: '.otf', mimeType: 'font/otf' } + ])( + 'serves authorized $extension fonts within the binary read cap', + async ({ extension, mimeType }) => { + const fixture = await createFixture() + const font = join(fixture.assets, `typeface${extension}`) + const bytes = Buffer.from([0x77, 0x4f, 0x46, 0x32, 0x00, 0x01, 0x00, 0x00]) + await writeFile(font, bytes) + const request = { + boundaryPath: fixture.workspace, + entryPath: fixture.entry, + implicitRootPath: fixture.docs, + authorizedRootPaths: [fixture.assets], + targetPath: font, + maxTextBytes: 1, + maxBinaryBytes: bytes.length + } + + await expect(readAuthorizedDocPreviewFile(request)).resolves.toEqual({ + content: bytes.toString('base64'), + isBinary: true, + mimeType + }) + await expect( + readAuthorizedDocPreviewFile({ ...request, authorizedRootPaths: [] }) + ).rejects.toThrow(DOC_PREVIEW_PATH_AUTHORIZATION_ERROR) + await expect( + readAuthorizedDocPreviewFile({ ...request, maxBinaryBytes: bytes.length - 1 }) + ).rejects.toThrow('file_too_large') + } + ) }) diff --git a/src/shared/doc-preview-file-access.ts b/src/shared/doc-preview-file-access.ts index 80413fef19b2..d7aca391393f 100644 --- a/src/shared/doc-preview-file-access.ts +++ b/src/shared/doc-preview-file-access.ts @@ -4,6 +4,7 @@ import { extname } from 'node:path' import { isBinaryBuffer } from './binary-buffer' import { isPathInsideOrEqual } from './cross-platform-path' import { IMAGE_FILE_MIME_TYPES } from './image-file-extensions' +import { FONT_FILE_MIME_TYPES } from './font-file-extensions' import { NodeFileReadTooLargeError, readNodeFileHandleWithinLimit @@ -29,6 +30,7 @@ export type DocPreviewFileAccessResult = { const DOC_PREVIEW_BINARY_MIME_TYPES: Record = { ...IMAGE_FILE_MIME_TYPES, + ...FONT_FILE_MIME_TYPES, '.pdf': 'application/pdf' } const DOC_PREVIEW_MAX_TEXT_BYTES = 10 * 1024 * 1024 diff --git a/src/shared/font-file-extensions.ts b/src/shared/font-file-extensions.ts new file mode 100644 index 000000000000..6de6cd923c4e --- /dev/null +++ b/src/shared/font-file-extensions.ts @@ -0,0 +1,6 @@ +export const FONT_FILE_MIME_TYPES: Record = { + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf' +} From ff5c284cc18a8cbebc3abd0b3a8854358fc1dd80 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:12:24 -0400 Subject: [PATCH 15/58] feat(mobile): relay a paired phone's server-workspace requests through the desktop (hidden) (#26435) * feat(runtime): let a paired desktop register its phones as delegated mobile devices A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(runtime): move the mobile device revoke primitive below dispatch Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in a layer under it as a pure move; this drops the abstract method seam. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): keep a runtime grant until its delegated phones are revoked A failed child cleanup now leaves the parent in place so a retry can still find the children, and revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage, shares credential minting, and drops the timing-dependent census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): fail a delegated sync whose dropped phone cannot be revoked Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors before minting anything, so the desktop retries instead of believing the server matches its list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): relay a paired phone's server-workspace requests through the desktop A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop now forwards a request that names `executionHost: runtime:` to that server over one socket per (phone, server), signed in as the phone's delegated device; replies pass through untouched except stream ids. Hidden until the phone client ships: the relay capability is not advertised yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the desktop relay contract in its entry module main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a later request syncs it again. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the relay's configured-server adapter The production hosts adapter was only typechecked; this pins that it resolves configured servers by exact id only (never a name or a removed server), fences by pairing revision and runtime identity, and stops reporting retirements once unsubscribed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): tighten the desktop relay after elegance review Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request whose link ended while opening, passes failure codes through, relays only this desktop's own phones, and drops the unused host-state reader until S2 needs one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound relay frames and stop sticky relay verdicts Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on each new link so an in-place upgrade relays, answers a phone the server just declined without syncing again, and ends a link whose mirrored capability update cannot be queued. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run targeted desktop-owned calls on the desktop The phone tags every call on a workspace screen with its execution host, so a paired-desktop method carrying `runtime:` now runs locally instead of failing 'forbidden'. The Mac stays the only reader of the routing table. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): run the remote-runtime WebSocket opener test on Node Under the bun vitest project, `ws` resolves to Bun's built-in client, which ignores maxPayload, so the frame-cap test hung. The opener only ships on Node/Electron, so run its test in the Node runtime project like its siblings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): derive the phone allowlist from the routing table Adding a phone method now takes one edit (its route). A server-targeted call on a legacy non-E2EE socket is refused instead of running locally, and only execution-host methods enter the relay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- config/scripts/vitest-node-runtime-files.mjs | 2 + ...ime-environment-mobile-relay-hosts.test.ts | 75 +++ .../runtime-environment-mobile-relay-hosts.ts | 40 ++ src/main/ipc/runtime-environments.ts | 2 + .../delegated-phone-grants.ts | 69 +++ .../mobile-desktop-relay-hosts.ts | 25 + .../mobile-desktop-relay.integration.test.ts | 488 ++++++++++++++++++ .../mobile-desktop-relay.test.ts | 286 ++++++++++ .../mobile-desktop-relay.ts | 325 ++++++++++++ .../relayed-phone-frames.test.ts | 120 +++++ .../relayed-phone-frames.ts | 117 +++++ .../relayed-stream-carriers.test.ts | 24 + .../relayed-stream-carriers.ts | 19 + src/main/runtime/rpc/core.ts | 2 + ...mobile-legacy-socket-relay-refusal.test.ts | 42 ++ .../runtime-rpc/runtime-rpc-lifecycle.ts | 1 + ...untime-rpc-mobile-agent-session-methods.ts | 2 +- .../runtime-rpc/runtime-rpc-mobile-devices.ts | 6 +- .../runtime-rpc-mobile-method-allowlist.ts | 286 +--------- .../runtime-rpc-mobile-method-routing.test.ts | 14 + .../runtime-rpc-mobile-method-routing.ts | 204 ++++++++ ...ntime-rpc-mobile-paired-desktop-methods.ts | 103 ++++ .../runtime-rpc/runtime-rpc-pairing.ts | 30 ++ .../runtime/runtime-rpc/runtime-rpc-state.ts | 3 + .../runtime-rpc-websocket-dispatch.ts | 82 +++ ...-process-runtime-launch-activation.test.ts | 4 + .../startup/main-process-runtime-launch.ts | 2 + .../delegated-mobile-device-contract.ts | 3 +- src/shared/mobile-desktop-relay-contract.ts | 4 + .../remote-runtime-passthrough-socket.ts | 175 +++++++ .../remote-runtime-request-websocket.test.ts | 40 ++ .../remote-runtime-request-websocket.ts | 16 +- src/shared/terminal-stream-protocol.ts | 17 + 33 files changed, 2341 insertions(+), 287 deletions(-) create mode 100644 src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts create mode 100644 src/main/ipc/runtime-environment-mobile-relay-hosts.ts create mode 100644 src/main/runtime/mobile-desktop-relay/delegated-phone-grants.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts create mode 100644 src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts create mode 100644 src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts create mode 100644 src/main/runtime/runtime-rpc-mobile-legacy-socket-relay-refusal.test.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts create mode 100644 src/shared/mobile-desktop-relay-contract.ts create mode 100644 src/shared/remote-runtime-passthrough-socket.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 2519b78ef035..77a972bb934b 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -77,6 +77,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/ws-empty-payload-frame-write.test.ts', 'src/shared/remote-runtime-client.test.ts', 'src/shared/remote-runtime-connect-bound.test.ts', + // Bun's built-in ws client ignores maxPayload; the product opener only runs on Node. + 'src/shared/remote-runtime-request-websocket.test.ts', 'src/shared/remote-runtime-subscription-connect-bound.test.ts', 'src/main/codex/codex-shared-server-probe.test.ts', 'src/main/opencode/opencode-launch-model-context-supervision.test.ts', diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts new file mode 100644 index 000000000000..99ac27a765d1 --- /dev/null +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const environments: { id: string; name: string }[] = [] + return { environments, resolveManaged: vi.fn(), call: vi.fn() } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/user-data' } })) +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: () => mocks.environments +})) +vi.mock('../../shared/runtime-environments', () => ({ + getPreferredPairingOffer: (environment: { endpoint: string }) => ({ + endpoint: environment.endpoint + }) +})) +vi.mock('./runtime-environment-managed-tunnel', () => ({ + resolveManagedRuntimeEnvironment: mocks.resolveManaged +})) +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.call +})) + +import { + createRuntimeEnvironmentMobileRelayHosts, + retireMobileDesktopRelayEnvironment +} from './runtime-environment-mobile-relay-hosts' + +describe('runtime environment mobile relay hosts', () => { + beforeEach(() => { + mocks.environments = [{ id: 'env-1', name: 'VM' }] + mocks.resolveManaged.mockReset().mockResolvedValue({ + id: 'env-1', + endpoint: 'ws://vm:6768', + pairingRevision: 7, + createdAt: 1, + runtimeId: 'runtime-a' + }) + mocks.call.mockReset() + }) + + it('resolves a configured server by exact id, fenced by pairing revision and runtime identity', async () => { + const hosts = createRuntimeEnvironmentMobileRelayHosts() + await expect(hosts.resolve('env-1')).resolves.toEqual({ + environmentId: 'env-1', + fence: '7\0runtime-a', + pairing: { endpoint: 'ws://vm:6768' } + }) + expect(mocks.resolveManaged).toHaveBeenCalledWith('/user-data', 'env-1') + }) + + it('answers null for a name, an unknown id or a removed server, without resolving it', async () => { + const hosts = createRuntimeEnvironmentMobileRelayHosts() + await expect(hosts.resolve('VM')).resolves.toBeNull() + await expect(hosts.resolve('env-2')).resolves.toBeNull() + mocks.environments = [] + await expect(hosts.resolve('env-1')).resolves.toBeNull() + expect(mocks.resolveManaged).not.toHaveBeenCalled() + }) + + it('calls the server as the desktop and reports retirements until unsubscribed', async () => { + const hosts = createRuntimeEnvironmentMobileRelayHosts() + mocks.call.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: { runtimeId: 'r' } }) + const host = (await hosts.resolve('env-1'))! + await hosts.call(host, 'status.get', undefined) + expect(mocks.call).toHaveBeenCalledWith('/user-data', 'env-1', 'status.get', undefined) + + const retired: string[] = [] + const stop = hosts.onEnvironmentRetired((id) => retired.push(id)) + retireMobileDesktopRelayEnvironment('env-1') + stop() + retireMobileDesktopRelayEnvironment('env-1') + expect(retired).toEqual(['env-1']) + }) +}) diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts new file mode 100644 index 000000000000..a18bfbfa37f5 --- /dev/null +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts @@ -0,0 +1,40 @@ +import { app } from 'electron' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import type { MobileDesktopRelayHosts } from '../runtime/mobile-desktop-relay/mobile-desktop-relay-hosts' +import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +const retirementListeners = new Set<(environmentId: string) => void>() + +/** Called wherever the desktop's own transport to a server is invalidated. */ +export function retireMobileDesktopRelayEnvironment(environmentId: string): void { + for (const listener of retirementListeners) { + listener(environmentId) + } +} + +/** The desktop's configured servers, addressed only by exact environment id. */ +export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHosts { + // Why userData: configured servers live where the runtime-environment handlers keep them. + const userDataPath = app.getPath('userData') + return { + resolve: async (environmentId) => { + if (!listEnvironments(userDataPath).some((entry) => entry.id === environmentId)) { + return null + } + const environment = await resolveManagedRuntimeEnvironment(userDataPath, environmentId) + return { + environmentId, + fence: `${environment.pairingRevision ?? environment.createdAt}\0${environment.runtimeId}`, + pairing: getPreferredPairingOffer(environment) + } + }, + call: (host, method, params) => + callRuntimeEnvironment(userDataPath, host.environmentId, method, params), + onEnvironmentRetired: (listener) => { + retirementListeners.add(listener) + return () => retirementListeners.delete(listener) + } + } +} diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 20c537486433..418d558fd96f 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -31,6 +31,7 @@ import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' +import { retireMobileDesktopRelayEnvironment } from './runtime-environment-mobile-relay-hosts' import { watchRuntimeEnvironmentPreference } from './runtime-environment-preference' const remoteRuntimeSubscriptions = new Map() @@ -73,6 +74,7 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr advanceRuntimeEnvironmentTransportGeneration(environmentId) closeRemoteRuntimeRequestConnection(environmentId) closeSubscriptionsForEnvironment(environmentId) + retireMobileDesktopRelayEnvironment(environmentId) reactivateRuntimeEnvironmentStatus(environmentId) return retirePairedRuntimeBrowserClientHostEnvironment( environmentId, diff --git a/src/main/runtime/mobile-desktop-relay/delegated-phone-grants.ts b/src/main/runtime/mobile-desktop-relay/delegated-phone-grants.ts new file mode 100644 index 000000000000..116f2cd5197d --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/delegated-phone-grants.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import { + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES, + DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY, + type DelegatedMobileDeviceSyncParams +} from '../../../shared/delegated-mobile-device-contract' +import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error' +import type { MobileDesktopRelayHost, MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' + +const SyncResultSchema = z.object({ + devices: z.array(z.object({ phoneKey: z.string(), deviceId: z.string(), token: z.string() })) +}) +const StatusCapabilitiesSchema = z.object({ capabilities: z.array(z.string()).optional() }) + +export type DelegatedPhoneGrant = { deviceId: string; token: string } + +/** One sync's outcome for a host; held in memory only, so the next sync is the only recovery. */ +export type DelegatedPhoneGrants = + | { kind: 'update-needed'; fence: string } + | { + kind: 'ready' + fence: string + grants: ReadonlyMap + // Every phone this sync asked for; one asked for but not granted waits for the next sync. + asked: ReadonlySet + // Phones whose host token was refused (revoked on the host); not retried until the next sync. + refused: Set + } + +export async function syncDelegatedPhoneGrants( + hosts: MobileDesktopRelayHosts, + host: MobileDesktopRelayHost, + phones: DelegatedMobileDeviceSyncParams['phones'] +): Promise { + const status = await hosts.call(host, 'status.get', undefined) + if (!status.ok) { + throw new RemoteRuntimeClientError('remote_runtime_unavailable', status.error.message) + } + const capabilities = StatusCapabilitiesSchema.safeParse(status.result).data?.capabilities ?? [] + if (!capabilities.includes(DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY)) { + return { kind: 'update-needed', fence: host.fence } + } + const response = await hosts.call(host, DELEGATED_MOBILE_DEVICE_SYNC_METHOD, { + phones: phones.slice(0, DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES) + }) + if (!response.ok) { + throw new RemoteRuntimeClientError('remote_runtime_unavailable', response.error.message) + } + const result = SyncResultSchema.safeParse(response.result) + if (!result.success) { + throw new RemoteRuntimeClientError( + 'invalid_runtime_response', + 'Remote Orca runtime returned an invalid delegated device sync.' + ) + } + return { + kind: 'ready', + fence: host.fence, + grants: new Map( + result.data.devices.map((device) => [ + device.phoneKey, + { deviceId: device.deviceId, token: device.token } + ]) + ), + asked: new Set(phones.map((phone) => phone.phoneKey)), + refused: new Set() + } +} diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts new file mode 100644 index 000000000000..ff7727f8c6e9 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts @@ -0,0 +1,25 @@ +import type { PairingOffer } from '../../../shared/pairing' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' + +/** A configured server the desktop can relay to. */ +export type MobileDesktopRelayHost = { + environmentId: string + // Pairing revision and runtime identity; delegated grants from another fence are never reused. + fence: string + // The desktop's own runtime-scope pairing; the relay swaps in a phone's delegated token. + pairing: PairingOffer +} + +/** The desktop's configured servers, as the relay sees them. */ +export type MobileDesktopRelayHosts = { + /** Null when the id names no configured server; a phone-supplied endpoint is never used. */ + resolve: (environmentId: string) => Promise + /** A call as the desktop itself, over its own connection to the server. */ + call: ( + host: MobileDesktopRelayHost, + method: string, + params: unknown + ) => Promise> + /** Fires when a server is removed, re-paired or disconnected; returns an unsubscribe. */ + onEnvironmentRetired: (listener: (environmentId: string) => void) => () => void +} diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts new file mode 100644 index 000000000000..e21d2b3b3d26 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts @@ -0,0 +1,488 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { z } from 'zod' +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyFromBase64, + publicKeyToBase64 +} from '../../../shared/e2ee-crypto' +import { parsePairingCode, type PairingOffer } from '../../../shared/pairing' +import { + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + DelegatedMobileDeviceSyncParamsSchema +} from '../../../shared/delegated-mobile-device-contract' +import { decodeTerminalStreamFrame } from '../../../shared/terminal-stream-protocol' +import { sendRemoteRuntimeRequest } from '../../../shared/remote-runtime-client' +import { OrcaRuntimeService } from '../orca-runtime' +import { readRuntimeMetadata } from '../runtime-metadata' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { sendRequest } from '../runtime-rpc-test-harness' +import { makeStore } from '../runtime-rpc-worktree-store-fixtures' +import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' +import type * as PassthroughSocketModule from '../../../shared/remote-runtime-passthrough-socket' + +const passthroughOpens = vi.hoisted(() => { + const hostReplies: string[] = [] + return { count: 0, hostReplies } +}) +vi.mock('../../../shared/remote-runtime-passthrough-socket', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + openRemoteRuntimePassthroughSocket: ( + ...[pairing, capabilities, callbacks, options]: Parameters< + typeof original.openRemoteRuntimePassthroughSocket + > + ) => { + passthroughOpens.count += 1 + return original.openRemoteRuntimePassthroughSocket( + pairing, + capabilities, + { + ...callbacks, + onText: (plaintext) => { + passthroughOpens.hostReplies.push(plaintext) + callbacks.onText(plaintext) + } + }, + options + ) + } + } +}) + +vi.mock('../../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +/** Exposes the per-terminal phone seats the runtime keeps behind a protected map. */ +class SeatObservableRuntime extends OrcaRuntimeService { + seatClientIds(ptyId: string): string[] { + return [...(this.mobileSubscribers.get(ptyId)?.keys() ?? [])] + } +} + +const WORKTREE = 'id:repo-1::/tmp/worktree-a' +const TARGET = 'runtime:env-1' +type Frame = Record + +describe('mobile desktop relay: phone -> desktop -> server', () => { + const cleanups: (() => Promise | void)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + async function startRuntime(spawnedOnThisHost: string[] = []) { + const writes: string[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store carries the surface these RPCs read, as in the sibling streaming tests. + const runtime = new SeatObservableRuntime(makeStore() as never) + runtime.setPtyController({ + spawn: vi.fn().mockImplementation(async () => { + spawnedOnThisHost.push('pty-shared') + return { id: 'pty-shared' } + }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + resize: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-relay-')) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + return { server, runtime, writes, userDataPath } + } + + function pair( + server: OrcaRuntimeRpcServer, + scope: 'mobile' | 'runtime', + name: string + ): PairingOffer { + const offer = server.createPairingOffer({ address: '127.0.0.1', name, scope }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + return parsePairingCode(offer.pairingUrl)! + } + + /** A server, and a desktop whose only configured server is it, as `env-1`. */ + async function startTopology() { + const host = await startRuntime() + const desktopSpawns: string[] = [] + const desktop = await startRuntime(desktopSpawns) + const desktopOnHost = pair(host.server, 'runtime', 'MacBook') + const registry = host.server.getDeviceRegistry()! + const children = () => registry.listDelegatedMobileDevices(desktopOnHost.pairedDeviceId!) + const syncedNames: string[][] = [] + const state = { capable: true, retire: (_id: string) => {} } + const hosts: MobileDesktopRelayHosts = { + resolve: async (environmentId) => + environmentId === 'env-1' + ? { environmentId, fence: 'pairing-1', pairing: desktopOnHost } + : null, + call: async (_host, method, params) => { + if (method === DELEGATED_MOBILE_DEVICE_SYNC_METHOD) { + const { phones } = DelegatedMobileDeviceSyncParamsSchema.parse(params) + syncedNames.push(phones.map((phone) => phone.name)) + } + const response = await sendRemoteRuntimeRequest(desktopOnHost, method, params, 5_000) + // An older server: the same status without the delegated-devices capability. + return method === 'status.get' && !state.capable && response.ok + ? { + ...response, + result: { ...z.object({}).passthrough().parse(response.result), capabilities: [] } + } + : response + }, + onEnvironmentRetired: (listener) => { + state.retire = listener + return () => {} + } + } + desktop.server.setMobileDesktopRelayHosts(hosts) + const metadata = readRuntimeMetadata(host.userDataPath)! + const created = await sendRequest(metadata.transports[0]!.endpoint, { + id: 'create', + authToken: metadata.authToken, + method: 'terminal.create', + params: { + worktree: WORKTREE, + command: 'bash', + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + activate: true + } + }) + const handle = z.object({ terminal: z.object({ handle: z.string() }) }).parse(created.result) + .terminal.handle + return { host, desktop, desktopSpawns, children, syncedNames, state, handle } + } + + async function connectPhone(desktop: OrcaRuntimeRpcServer, name: string) { + const pairing = pair(desktop, 'mobile', name) + const keys = generateKeyPair() + const sharedKey = deriveSharedKey(keys.secretKey, publicKeyFromBase64(pairing.publicKeyB64)) + const ws = new WebSocket(pairing.endpoint) + const frames: Frame[] = [] + const binaries: Uint8Array[] = [] + let authenticated = false + const ready = new Promise((resolve) => { + ws.on('message', (data: Buffer, isBinary) => { + if (isBinary) { + binaries.push(decryptBytes(new Uint8Array(data), sharedKey)!) + return + } + const text = data.toString() + if (text.includes('e2ee_ready')) { + ws.send( + encrypt( + JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), + sharedKey + ) + ) + return + } + const frame = z.record(z.string(), z.unknown()).parse(JSON.parse(decrypt(text, sharedKey)!)) + if (!authenticated) { + authenticated = frame.type === 'e2ee_authenticated' + resolve() + return + } + frames.push(frame) + }) + }) + ws.once('open', () => + ws.send( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: publicKeyToBase64(keys.publicKey) }) + ) + ) + await ready + cleanups.push(() => ws.close()) + const token = pairing.deviceToken + return { + token, + deviceId: pairing.pairedDeviceId!, + frames, + binaries, + close: () => ws.close(), + send(id: string, method: string, params: unknown, executionHost: string | null = TARGET) { + ws.send( + encrypt( + JSON.stringify({ + id, + deviceToken: token, + method, + params, + executionHost: executionHost ?? undefined + }), + sharedKey + ) + ) + }, + next: (id: string, predicate: (frame: Frame) => boolean = () => true) => + vi.waitFor(() => { + const frame = frames.find((candidate) => candidate.id === id && predicate(candidate)) + expect(frame).toBeDefined() + return frame! + }) + } + } + type Phone = Awaited> + + async function subscribe(phone: Phone, handle: string, id = 'sub') { + phone.send(id, 'terminal.subscribe', { + terminal: handle, + client: { id: phone.token, type: 'mobile' }, + viewport: { cols: 40, rows: 20 }, + capabilities: { terminalBinaryStream: 1 } + }) + const subscribed = await phone.next( + id, + (frame) => + z.object({ result: z.object({ type: z.literal('subscribed') }) }).safeParse(frame).success + ) + return z.object({ result: z.object({ streamId: z.number() }) }).parse(subscribed).result + .streamId + } + + const isOk = (frame: Frame) => frame.ok === true + const errorCode = (frame: Frame) => + z.object({ error: z.object({ code: z.string() }) }).parse(frame).error.code + const sendAccepted = (frame: Frame) => + z.object({ result: z.object({ send: z.object({ accepted: z.boolean() }) }) }).parse(frame) + .result.send.accepted + + it('relays a terminal subscribe, its binary stream, input and a query reply as the phone delegated device', async () => { + const { host, desktop, children, syncedNames, handle } = await startTopology() + // A phone another desktop relays to this one is that desktop's to relay, never re-relayed. + const registry = desktop.server.getDeviceRegistry()! + const otherDesktop = registry.getDevice( + pair(desktop.server, 'runtime', 'Other Mac').pairedDeviceId! + )! + registry.upsertDelegatedMobileDevices(otherDesktop, [ + { phoneKey: 'p', name: 'iPad via Other Mac' } + ]) + const phone = await connectPhone(desktop.server, 'iPhone') + + const streamId = await subscribe(phone, handle) + // Host and desktop share a process counter here, so compare against the id the host sent. + const hostStreamId = z + .object({ + id: z.literal('sub'), + result: z.object({ type: z.literal('subscribed'), streamId: z.number() }) + }) + .parse( + passthroughOpens.hostReplies + .map((frame) => JSON.parse(frame)) + .find((frame) => frame.id === 'sub' && frame.result?.type === 'subscribed') + ).result.streamId + expect(streamId).not.toBe(hostStreamId) + // The snapshot arrives as binary frames under the id the phone was given, never the host's. + await vi.waitFor(() => + expect( + phone.binaries.some((bytes) => decodeTerminalStreamFrame(bytes)?.streamId === streamId) + ).toBe(true) + ) + expect( + phone.binaries.some((bytes) => decodeTerminalStreamFrame(bytes)?.streamId === hostStreamId) + ).toBe(false) + expect(syncedNames).toEqual([[`iPhone via ${desktop.runtime.readMachineName()}`]]) + const delegated = children()[0]! + // The host seats the phone's delegated device, never the desktop's token for it. + expect(host.runtime.seatClientIds('pty-shared')).toEqual([delegated.token]) + + phone.send('in', 'terminal.send', { + terminal: handle, + text: 'ls', + client: { id: phone.token, type: 'mobile' } + }) + expect(isOk(await phone.next('in'))).toBe(true) + expect(host.writes).toContain('ls') + // terminal.send cross-checks client.id against the socket's device: only a swapped token passes. + phone.send('query', 'terminal.send', { + terminal: handle, + text: '\x1b[0n', + enter: false, + inputKind: 'query-reply', + client: { id: phone.token, type: 'mobile' } + }) + expect(sendAccepted(await phone.next('query'))).toBe(true) + expect(host.writes).toContain('\x1b[0n') + }) + + it('gives two phones on one server terminal their own seats, and cleans up the one that disconnects', async () => { + const { host, desktop, children, handle } = await startTopology() + const phoneA = await connectPhone(desktop.server, 'Phone A') + const phoneB = await connectPhone(desktop.server, 'Phone B') + const streamA = await subscribe(phoneA, handle) + const streamB = await subscribe(phoneB, handle) + expect(streamA).not.toBe(streamB) + const tokens = children().map((device) => device.token) + expect(new Set(tokens).size).toBe(2) + expect(host.runtime.seatClientIds('pty-shared').sort()).toEqual([...tokens].sort()) + + phoneA.close() + await vi.waitFor(() => expect(host.runtime.seatClientIds('pty-shared')).toHaveLength(1)) + expect(host.runtime.seatClientIds('pty-shared')).not.toContain(tokens[0]) + }) + + it('applies a phone capability update on the desktop and on its open server socket, answering once', async () => { + const { desktop, handle } = await startTopology() + const phone = await connectPhone(desktop.server, 'iPhone') + await subscribe(phone, handle) + phone.send( + 'caps', + 'runtime.clientCapabilities.update', + { clientCapabilities: ['relay.test.v1'] }, + null + ) + expect(await phone.next('caps')).toMatchObject({ + ok: true, + result: { clientCapabilities: ['relay.test.v1'] } + }) + // The server applied it to the relayed phone's socket; its answer stays with the relay. + await vi.waitFor(() => + expect( + passthroughOpens.hostReplies.some((frame) => + frame.includes('"clientCapabilities":["relay.test.v1"]') + ) + ).toBe(true) + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect( + phone.frames.filter((frame) => frame.id === 'caps' || String(frame.id).startsWith('relay-')) + ).toHaveLength(1) + }) + + it('drops a phone from the server as soon as the desktop unpairs it', async () => { + const { host, desktop, children, handle } = await startTopology() + const kept = await connectPhone(desktop.server, 'Kept') + const unpaired = await connectPhone(desktop.server, 'Unpaired') + await subscribe(kept, handle) + expect(children().map((device) => device.phoneKey)).toEqual([kept.deviceId, unpaired.deviceId]) + + await desktop.server.revokeMobileDevice(unpaired.deviceId) + await vi.waitFor(() => + expect(children().map((device) => device.phoneKey)).toEqual([kept.deviceId]) + ) + expect(host.runtime.seatClientIds('pty-shared')).toEqual([children()[0]!.token]) + }) + + it('stops relaying a phone the server revoked until the next sync', async () => { + const { host, desktop, children, state, handle } = await startTopology() + const phone = await connectPhone(desktop.server, 'iPhone') + await subscribe(phone, handle) + const revoked = children()[0]! + + await host.server.revokeMobileDevice(revoked.deviceId) + // The open stream ends as unavailable, never as an unpaired phone. + expect(errorCode(await phone.next('sub', (frame) => frame.ok === false))).toBe( + 'remote_runtime_unavailable' + ) + const opensBefore = passthroughOpens.count + phone.send('after-1', 'terminal.list', {}) + expect(errorCode(await phone.next('after-1'))).toBe('remote_runtime_unavailable') + phone.send('after-2', 'terminal.list', {}) + expect(errorCode(await phone.next('after-2'))).toBe('remote_runtime_unavailable') + // One refused attempt, then no retries of the dead token. + expect(passthroughOpens.count - opensBefore).toBe(1) + + state.retire('env-1') + phone.send('after-sync', 'terminal.list', {}) + expect(isOk(await phone.next('after-sync'))).toBe(true) + }) + + it('answers a targeted desktop-owned call on the desktop, exactly as the untargeted one', async () => { + const { desktop } = await startTopology() + const phone = await connectPhone(desktop.server, 'iPhone') + const opensBefore = passthroughOpens.count + + for (const method of ['settings.get', 'status.get']) { + phone.send(`${method}:targeted`, method, {}) + phone.send(`${method}:untargeted`, method, {}, null) + const targeted = await phone.next(`${method}:targeted`) + const untargeted = await phone.next(`${method}:untargeted`) + expect(isOk(targeted)).toBe(true) + expect(targeted.result).toEqual(untargeted.result) + } + expect(passthroughOpens.count).toBe(opensBefore) + // An execution-host method under the same target still relays. + phone.send('list', 'terminal.list', {}) + expect(isOk(await phone.next('list'))).toBe(true) + expect(passthroughOpens.count).toBe(opensBefore + 1) + }) + + it('refuses binary-frame methods and unknown servers without running anything on the desktop', async () => { + const { desktop, desktopSpawns } = await startTopology() + const phone = await connectPhone(desktop.server, 'iPhone') + + phone.send('multiplex', 'terminal.multiplex', {}) + expect(errorCode(await phone.next('multiplex'))).toBe('forbidden') + const create = { + worktree: WORKTREE, + command: 'bash', + tabId: 'tab-2', + leafId: '22222222-2222-4222-8222-222222222222' + } + phone.send('unknown', 'terminal.create', create, 'runtime:env-unknown') + expect(errorCode(await phone.next('unknown'))).toBe('remote_runtime_unavailable') + phone.send('garbled', 'terminal.create', create, 'runtime:') + expect(errorCode(await phone.next('garbled'))).toBe('invalid_argument') + expect(desktopSpawns).toEqual([]) + // No target, `local` and the desktop's own SSH hosts are today's local call. + const opensBefore = passthroughOpens.count + phone.send('absent', 'terminal.list', {}, null) + phone.send('local', 'terminal.list', {}, 'local') + phone.send('ssh', 'terminal.list', {}, 'ssh:devbox') + for (const id of ['absent', 'local', 'ssh']) { + expect(isOk(await phone.next(id))).toBe(true) + } + expect(passthroughOpens.count).toBe(opensBefore) + }) + + it('reports a server without delegated devices as update-needed and relays nothing to it', async () => { + const { desktop, state } = await startTopology() + state.capable = false + const phone = await connectPhone(desktop.server, 'iPhone') + const opensBefore = passthroughOpens.count + phone.send('list', 'terminal.list', {}) + expect(await phone.next('list')).toMatchObject({ + error: { code: 'remote_runtime_unavailable', message: expect.stringContaining('update') } + }) + expect(passthroughOpens.count).toBe(opensBefore) + state.capable = true + state.retire('env-1') + phone.send('updated', 'terminal.list', {}) + expect(isOk(await phone.next('updated'))).toBe(true) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts new file mode 100644 index 000000000000..46e9a86c5f2b --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest' +import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error' +import type { RemoteRuntimePassthroughCallbacks } from '../../../shared/remote-runtime-passthrough-socket' +import { MobileDesktopRelay, type RelayedPhone } from './mobile-desktop-relay' +import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' + +type FakeSocket = { + sent: string[] + capabilities: readonly string[] + callbacks: RemoteRuntimePassthroughCallbacks + closed: boolean + accepts: boolean +} +const sockets = vi.hoisted(() => { + const opened: FakeSocket[] = [] + return opened +}) +// Holds every socket open until released, so a test can act while one is still connecting. +const openGate = vi.hoisted(() => ({ wait: Promise.resolve() })) +vi.mock('../../../shared/remote-runtime-passthrough-socket', () => ({ + openRemoteRuntimePassthroughSocket: async ( + _pairing: unknown, + capabilities: readonly string[], + callbacks: RemoteRuntimePassthroughCallbacks + ) => { + const socket: FakeSocket = { sent: [], capabilities, callbacks, closed: false, accepts: true } + sockets.push(socket) + await openGate.wait + return { + send: (frame: string) => { + socket.sent.push(frame) + return socket.accepts + }, + close: () => { + socket.closed = true + } + } + } +})) + +const ok = (result: unknown) => ({ id: 'x', ok: true as const, result, _meta: { runtimeId: 'h' } }) + +const SYNCED = ok({ + devices: [ + { phoneKey: 'phone-1', deviceId: 'child', token: 'host-token' }, + { phoneKey: 'phone-2', deviceId: 'child-2', token: 'host-token-2' } + ] +}) + +function relayWithPhone( + syncResponse: () => RuntimeRpcResponse | Promise> = () => + SYNCED +) { + const syncCalls: unknown[] = [] + const server = { capable: true } + const hosts: MobileDesktopRelayHosts = { + resolve: async (environmentId) => ({ + environmentId, + fence: 'f', + pairing: { v: 2, endpoint: 'ws://127.0.0.1:1', deviceToken: 'desktop', publicKeyB64: 'k' } + }), + call: async (_host, method, params) => { + if (method === 'status.get') { + return ok({ + capabilities: server.capable ? [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] : [] + }) + } + syncCalls.push(params) + return syncResponse() + }, + onEnvironmentRetired: () => () => {} + } + const relay = new MobileDesktopRelay({ + hosts, + listPhones: () => [ + { phoneKey: 'phone-1', name: 'iPhone via Mac' }, + { phoneKey: 'phone-2', name: 'iPad via Mac' } + ], + allocateStreamId: () => 1 + }) + const replies: string[] = [] + let capabilities: readonly string[] = ['cap.a'] + const phone: RelayedPhone = { + connectionId: 'conn-1', + deviceId: 'phone-1', + deviceToken: 'desktop-token', + clientCapabilities: () => capabilities, + reply: (frame) => replies.push(frame), + sendBinary: () => true + } + return { + relay, + phone, + replies, + syncCalls, + server, + setCapabilities: (next: string[]) => (capabilities = next) + } +} + +describe('MobileDesktopRelay', () => { + it('signs in with exactly the phone capabilities and mirrors its updates without a second answer', async () => { + sockets.length = 0 + const { relay, phone, replies, setCapabilities } = relayWithPhone() + relay.forward( + phone, + 'env-1', + { id: 'r1', method: 'terminal.list' }, + '{"id":"r1","method":"terminal.list"}' + ) + relay.forward( + phone, + 'env-2', + { id: 'r2', method: 'terminal.list' }, + '{"id":"r2","method":"terminal.list"}' + ) + await vi.waitFor(() => expect(sockets.map((socket) => socket.sent.length)).toEqual([1, 1])) + expect(sockets[0]!.capabilities).toEqual(['cap.a']) + + setCapabilities(['cap.b']) + const update = JSON.stringify({ + id: 'caps', + deviceToken: 'desktop-token', + method: 'runtime.clientCapabilities.update', + params: { clientCapabilities: ['cap.b'] } + }) + relay.forwardClientCapabilities('conn-1', update) + relay.forwardClientCapabilities('other-connection', update) + await vi.waitFor(() => expect(sockets.map((socket) => socket.sent.length)).toEqual([2, 2])) + for (const socket of sockets) { + const mirrored = JSON.parse(socket.sent[1]!) + expect(mirrored).toMatchObject({ + method: 'runtime.clientCapabilities.update', + params: { clientCapabilities: ['cap.b'] } + }) + expect(mirrored.id).not.toBe('caps') + expect(mirrored.deviceToken).toBeUndefined() + // The host's answer to the mirror is the relay's, not the phone's. + socket.callbacks.onText(JSON.stringify({ id: mirrored.id, ok: true, result: {}, _meta: {} })) + } + expect(replies).toEqual([]) + }) + + it('answers every request still open when the server socket drops, then closes on phone disconnect', async () => { + sockets.length = 0 + const { relay, phone, replies } = relayWithPhone() + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + relay.forward(phone, 'env-1', { id: 'b', method: 'terminal.list' }, '{"id":"b"}') + await vi.waitFor(() => expect(sockets[0]?.sent).toHaveLength(2)) + // One socket per (phone connection, server) carries both requests. + expect(sockets).toHaveLength(1) + sockets[0]!.callbacks.onText('{"id":"a","ok":true,"result":{},"_meta":{"runtimeId":"h"}}') + sockets[0]!.callbacks.onClose( + new RemoteRuntimeClientError('remote_runtime_unavailable', 'gone') + ) + expect(replies.map((frame) => JSON.parse(frame))).toEqual([ + { id: 'a', ok: true, result: {}, _meta: { runtimeId: 'h' } }, + expect.objectContaining({ + id: 'b', + ok: false, + error: expect.objectContaining({ code: 'remote_runtime_unavailable' }) + }) + ]) + + relay.forward(phone, 'env-1', { id: 'c', method: 'terminal.list' }, '{"id":"c"}') + await vi.waitFor(() => expect(sockets).toHaveLength(2)) + relay.closePhoneConnection('conn-1') + await vi.waitFor(() => expect(sockets[1]!.closed).toBe(true)) + }) + + it('treats a server whose sync failed as unavailable, and syncs again on the next request', async () => { + sockets.length = 0 + let failing = true + const { relay, phone, replies } = relayWithPhone(() => + failing + ? { + id: 'x', + ok: false, + error: { code: 'runtime_error', message: 'delegated_device_revoke_failed' } + } + : ok({ devices: [{ phoneKey: 'phone-1', deviceId: 'child', token: 'host-token' }] }) + ) + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + await vi.waitFor(() => expect(replies).toHaveLength(1)) + expect(JSON.parse(replies[0]!)).toMatchObject({ + id: 'a', + error: { code: 'remote_runtime_unavailable' } + }) + expect(sockets).toHaveLength(0) + + failing = false + relay.forward(phone, 'env-1', { id: 'b', method: 'terminal.list' }, '{"id":"b"}') + await vi.waitFor(() => expect(sockets[0]?.sent).toHaveLength(1)) + }) + + it('runs one sync when two phones race to the same server', async () => { + sockets.length = 0 + const { relay, phone, syncCalls } = relayWithPhone() + const tablet: RelayedPhone = { ...phone, connectionId: 'conn-2', deviceId: 'phone-2' } + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + relay.forward(tablet, 'env-1', { id: 'b', method: 'terminal.list' }, '{"id":"b"}') + await vi.waitFor(() => expect(sockets.map((socket) => socket.sent.length)).toEqual([1, 1])) + expect(syncCalls).toHaveLength(1) + }) + + it('closes a socket still opening when its server is removed, answering and never sending its requests', async () => { + sockets.length = 0 + let release = () => {} + openGate.wait = new Promise((resolve) => (release = resolve)) + const { relay, phone, replies } = relayWithPhone() + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + relay.retireEnvironment('env-1') + expect(JSON.parse(replies[0]!)).toMatchObject({ + id: 'a', + error: { code: 'remote_runtime_unavailable' } + }) + release() + await vi.waitFor(() => expect(sockets[0]!.closed).toBe(true)) + expect(sockets[0]!.sent).toEqual([]) + openGate.wait = Promise.resolve() + }) + + it('opens nothing for a phone that disconnects while its server syncs, and answers nobody', async () => { + sockets.length = 0 + let finishSync = () => {} + const { relay, phone, replies, syncCalls } = relayWithPhone( + () => new Promise((resolve) => (finishSync = () => resolve(SYNCED))) + ) + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + await vi.waitFor(() => expect(syncCalls).toHaveLength(1)) + relay.closePhoneConnection('conn-1') + finishSync() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(sockets).toEqual([]) + expect(replies).toEqual([]) + }) + + it('relays to a server upgraded in place without waiting for a re-pair', async () => { + sockets.length = 0 + const { relay, phone, replies, server } = relayWithPhone() + server.capable = false + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + await vi.waitFor(() => expect(replies).toHaveLength(1)) + expect(JSON.parse(replies[0]!).error.message).toContain('update') + server.capable = true + relay.forward(phone, 'env-1', { id: 'b', method: 'terminal.list' }, '{"id":"b"}') + await vi.waitFor(() => expect(sockets[0]?.sent).toHaveLength(1)) + }) + + it('does not sync again for a phone the server just declined to grant', async () => { + sockets.length = 0 + const { relay, phone, replies, syncCalls } = relayWithPhone(() => + ok({ devices: [{ phoneKey: 'phone-2', deviceId: 'child-2', token: 'host-token-2' }] }) + ) + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + await vi.waitFor(() => expect(replies).toHaveLength(1)) + relay.forward(phone, 'env-1', { id: 'b', method: 'terminal.list' }, '{"id":"b"}') + await vi.waitFor(() => expect(replies).toHaveLength(2)) + expect(replies.map((frame) => JSON.parse(frame).error.code)).toEqual([ + 'remote_runtime_unavailable', + 'remote_runtime_unavailable' + ]) + expect(syncCalls).toHaveLength(1) + expect(sockets).toEqual([]) + }) + + it('ends the link when a mirrored capability update cannot be queued', async () => { + sockets.length = 0 + const { relay, phone, replies } = relayWithPhone() + relay.forward(phone, 'env-1', { id: 'a', method: 'terminal.list' }, '{"id":"a"}') + await vi.waitFor(() => expect(sockets[0]?.sent).toHaveLength(1)) + sockets[0]!.accepts = false + relay.forwardClientCapabilities( + 'conn-1', + '{"id":"caps","method":"runtime.clientCapabilities.update"}' + ) + await vi.waitFor(() => expect(sockets[0]!.closed).toBe(true)) + expect(JSON.parse(replies[0]!)).toMatchObject({ + id: 'a', + error: { code: 'remote_runtime_unavailable' } + }) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts new file mode 100644 index 000000000000..20386ee042c8 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.ts @@ -0,0 +1,325 @@ +import { randomUUID } from 'node:crypto' +import type { DelegatedMobileDeviceSyncParams } from '../../../shared/delegated-mobile-device-contract' +import type { RuntimeCapability } from '../../../shared/protocol-version' +import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error' +import { + openRemoteRuntimePassthroughSocket, + type RemoteRuntimePassthroughSocket +} from '../../../shared/remote-runtime-passthrough-socket' +import { syncDelegatedPhoneGrants, type DelegatedPhoneGrants } from './delegated-phone-grants' +import type { MobileDesktopRelayHost, MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' +import { RELAYED_STREAM_CARRIERS } from './relayed-stream-carriers' +import { RelayedPhoneReplies, rewriteRelayedPhoneRequest } from './relayed-phone-frames' + +const RELAY_CONNECT_TIMEOUT_MS = 15_000 + +/** The phone connection a relayed request arrived on. */ +export type RelayedPhone = { + connectionId: string + deviceId: string + deviceToken: string + clientCapabilities: () => readonly RuntimeCapability[] + reply: (frame: string) => void + sendBinary: (bytes: Uint8Array) => boolean | void +} + +type GrantsEntry = { + promise: Promise + settled: boolean + result?: DelegatedPhoneGrants +} + +type OpenedRelayLink = { socket: RemoteRuntimePassthroughSocket; hostToken: string } + +class RelayLink { + readonly replies: RelayedPhoneReplies + readonly opened: Promise + + constructor( + readonly environmentId: string, + readonly phone: RelayedPhone, + allocateStreamId: () => number, + open: (link: RelayLink) => Promise + ) { + this.replies = new RelayedPhoneReplies(allocateStreamId) + this.opened = open(this) + } +} + +/** + * Relays a phone request carrying `executionHost: runtime:` to that configured server, over one + * socket per (phone connection, server) signed in as the phone's delegated device (minted by + * `pairing.delegatedMobileDevice.sync`, tokens held in memory only). Contract: + * - The desktop never interprets replies; it renumbers only host stream ids (RELAYED_STREAM_CARRIERS). + * - Upstream, the phone's desktop token is swapped wherever it appears, not per field. + * - Only `execution-host` methods (MOBILE_RPC_METHOD_ROUTES) reach here; `paired-desktop` ones run locally. + * - A server without the delegated-devices capability is `update-needed`; nothing relays to it. + * - A server-side revoke of a phone is final until the next sync. + * - Hidden until S2: MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY is not advertised yet. + */ +export class MobileDesktopRelay { + private readonly links = new Map() + private readonly grants = new Map() + private readonly stopRetirementWatch: () => void + + constructor( + private readonly options: { + hosts: MobileDesktopRelayHosts + listPhones: () => DelegatedMobileDeviceSyncParams['phones'] + allocateStreamId: () => number + } + ) { + this.stopRetirementWatch = options.hosts.onEnvironmentRetired((environmentId) => + this.retireEnvironment(environmentId) + ) + } + + dispose(): void { + this.stopRetirementWatch() + for (const link of this.links.values()) { + this.endLink(link, unavailable('The paired desktop stopped relaying.')) + } + this.grants.clear() + } + + forward( + phone: RelayedPhone, + environmentId: string, + request: { id: string; method: string }, + frame: string + ): void { + if (RELAYED_STREAM_CARRIERS.get(request.method) === 'phone-binary-frames') { + phone.reply( + failure( + request.id, + 'forbidden', + `Method '${request.method}' is driven by binary frames, which the desktop does not relay` + ) + ) + return + } + const link = this.linkFor(phone, environmentId) + link.replies.noteForwarded(request.id) + link.opened.then( + ({ socket, hostToken }) => { + // Why: a link ended while opening already answered this request; never send it late. + if (!this.isCurrent(link)) { + return + } + if (!socket.send(rewriteRelayedPhoneRequest(frame, phone.deviceToken, hostToken))) { + this.endLink(link, unavailable('The server connection could not take the request.')) + } + }, + () => { + // The open failure already answered every request queued on this link. + } + ) + } + + /** Mirrors a phone's capability update to every server socket that phone has open. */ + forwardClientCapabilities(connectionId: string, frame: string): void { + for (const link of this.links.values()) { + if (link.phone.connectionId !== connectionId) { + continue + } + // Why a fresh id: the phone already has the desktop's answer to its own id. + const id = `relay-capabilities:${randomUUID()}` + link.replies.noteForwarded(id, { swallowReplies: true }) + link.opened.then( + ({ socket, hostToken }) => { + if ( + this.isCurrent(link) && + !socket.send(rewriteRelayedPhoneRequest(frame, link.phone.deviceToken, hostToken, id)) + ) { + this.endLink(link, unavailable('The server connection could not take the request.')) + } + }, + () => {} + ) + } + } + + closePhoneConnection(connectionId: string): void { + for (const link of this.links.values()) { + if (link.phone.connectionId === connectionId) { + this.endLink(link, null) + } + } + } + + /** The server was removed, re-paired or disconnected: drop its sockets and grants. */ + retireEnvironment(environmentId: string): void { + this.grants.delete(environmentId) + for (const link of this.links.values()) { + if (link.environmentId === environmentId) { + this.endLink(link, unavailable('The server connection was reset.')) + } + } + } + + /** The desktop's paired phones changed: re-sync every server already holding grants. */ + phonesChanged(): void { + for (const environmentId of this.grants.keys()) { + this.grants.delete(environmentId) + void this.resync(environmentId) + } + } + + private async resync(environmentId: string): Promise { + try { + const host = await this.options.hosts.resolve(environmentId) + if (host) { + await this.grantsFor(host, null) + } + } catch { + // An unreachable server syncs again on its next relayed request. + } + } + + private linkFor(phone: RelayedPhone, environmentId: string): RelayLink { + const key = linkKey(phone.connectionId, environmentId) + const existing = this.links.get(key) + if (existing) { + return existing + } + const link = new RelayLink(environmentId, phone, this.options.allocateStreamId, (opening) => + this.open(opening) + ) + link.opened.catch((error: unknown) => { + this.endLink( + link, + error instanceof RemoteRuntimeClientError ? error : unavailable(String(error)) + ) + }) + this.links.set(key, link) + return link + } + + private async open(link: RelayLink): Promise { + const { phone } = link + const host = await this.options.hosts.resolve(link.environmentId) + if (!host) { + throw unavailable('The server is not configured on this computer.') + } + const grants = await this.grantsFor(host, phone.deviceId) + if (grants.kind === 'update-needed') { + throw unavailable('The server needs an Orca update to open its workspaces from a phone.') + } + // Why: the phone left or the server was retired while syncing; open nothing for it. + if (!this.isCurrent(link)) { + throw unavailable('The relayed request was cancelled.') + } + const grant = grants.grants.get(phone.deviceId) + if (!grant || grants.refused.has(phone.deviceId)) { + throw unavailable('The server does not accept this phone.') + } + try { + const socket = await openRemoteRuntimePassthroughSocket( + { + ...host.pairing, + deviceToken: grant.token, + pairedDeviceId: grant.deviceId, + scope: 'mobile' + }, + phone.clientCapabilities(), + { + onText: (plaintext) => { + const frame = link.replies.text(plaintext) + if (frame !== null) { + phone.reply(frame) + } + }, + onBinary: (bytes) => { + const frame = link.replies.binary(bytes) + if (frame) { + phone.sendBinary(frame) + } + }, + onClose: (error) => this.endLink(link, error) + }, + { timeoutMs: RELAY_CONNECT_TIMEOUT_MS } + ) + return { socket, hostToken: grant.token } + } catch (error) { + if (error instanceof RemoteRuntimeClientError && error.code === 'unauthorized') { + // Why: retrying a revoked token would only raise the host's "unpaired device" prompt. + grants.refused.add(phone.deviceId) + throw unavailable('The server revoked this phone.') + } + throw error + } + } + + private grantsFor( + host: MobileDesktopRelayHost, + phoneKey: string | null + ): Promise { + const cached = this.grants.get(host.environmentId) + if (cached && !cached.settled) { + // Why: phones racing to one server share its sync. + return cached.promise.then((grants) => + grants.fence === host.fence ? grants : this.grantsFor(host, phoneKey) + ) + } + const answered = + cached?.result?.kind === 'ready' && + cached.result.fence === host.fence && + (phoneKey === null || cached.result.asked.has(phoneKey)) + // Why update-needed is never reused: a server upgraded in place must relay without a re-pair. + return answered && cached.result ? Promise.resolve(cached.result) : this.sync(host) + } + + private sync(host: MobileDesktopRelayHost): Promise { + const entry: GrantsEntry = { + promise: syncDelegatedPhoneGrants(this.options.hosts, host, this.options.listPhones()), + settled: false + } + this.grants.set(host.environmentId, entry) + entry.promise.then( + (result) => { + entry.settled = true + entry.result = result + }, + () => { + // Why: an unreachable server is not an answer; the next request syncs again. + if (this.grants.get(host.environmentId) === entry) { + this.grants.delete(host.environmentId) + } + } + ) + return entry.promise + } + + private isCurrent(link: RelayLink): boolean { + return this.links.get(linkKey(link.phone.connectionId, link.environmentId)) === link + } + + private endLink(link: RelayLink, error: RemoteRuntimeClientError | null): void { + if (!this.isCurrent(link)) { + return + } + this.links.delete(linkKey(link.phone.connectionId, link.environmentId)) + link.opened.then( + ({ socket }) => socket.close(), + () => {} + ) + const openRequestIds = link.replies.takeOpenRequestIds() + if (error) { + for (const id of openRequestIds) { + link.phone.reply(failure(id, error.code, error.message)) + } + } + } +} + +function linkKey(connectionId: string, environmentId: string): string { + return `${connectionId}\0${environmentId}` +} + +function unavailable(message: string): RemoteRuntimeClientError { + return new RemoteRuntimeClientError('remote_runtime_unavailable', message) +} + +function failure(id: string, code: string, message: string): string { + return JSON.stringify({ id, ok: false, error: { code, message }, _meta: { runtimeId: null } }) +} diff --git a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts new file mode 100644 index 000000000000..d05b44a2439b --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + encodeTerminalStreamFrame, + decodeTerminalStreamFrame, + TerminalStreamOpcode +} from '../../../shared/terminal-stream-protocol' +import { + BrowserScreencastOpcode, + encodeBrowserScreencastFrame +} from '../../../shared/browser-screencast-protocol' +import { RelayedPhoneReplies, rewriteRelayedPhoneRequest } from './relayed-phone-frames' + +const DESKTOP_TOKEN = 'a'.repeat(48) +const HOST_TOKEN = 'b'.repeat(48) + +describe('rewriteRelayedPhoneRequest', () => { + it('swaps the desktop token everywhere and strips the envelope routing fields', () => { + const frame = JSON.stringify({ + id: 'r1', + deviceToken: DESKTOP_TOKEN, + executionHost: 'runtime:env-1', + method: 'terminal.unsubscribe', + params: { + subscriptionId: `terminal-1:${DESKTOP_TOKEN}`, + client: { id: DESKTOP_TOKEN, type: 'mobile' }, + nested: [{ mobileClient: { id: DESKTOP_TOKEN } }] + } + }) + const rewritten = rewriteRelayedPhoneRequest(frame, DESKTOP_TOKEN, HOST_TOKEN) + expect(frame).toContain(DESKTOP_TOKEN) + expect(rewritten).not.toContain(DESKTOP_TOKEN) + expect(JSON.parse(rewritten)).toEqual({ + id: 'r1', + method: 'terminal.unsubscribe', + params: { + subscriptionId: `terminal-1:${HOST_TOKEN}`, + client: { id: HOST_TOKEN, type: 'mobile' }, + nested: [{ mobileClient: { id: HOST_TOKEN } }] + } + }) + }) + + it('can re-id a frame the relay sends on its own behalf', () => { + const frame = JSON.stringify({ id: 'phone-id', method: 'm', params: {} }) + expect( + JSON.parse(rewriteRelayedPhoneRequest(frame, DESKTOP_TOKEN, HOST_TOKEN, 'relay-id')) + ).toEqual({ id: 'relay-id', method: 'm', params: {} }) + }) +}) + +describe('RelayedPhoneReplies', () => { + function replies(firstDesktopId = 100) { + let next = firstDesktopId + return new RelayedPhoneReplies(() => next++) + } + const subscribed = (id: string, streamId: number) => + JSON.stringify({ + id, + ok: true, + streaming: true, + result: { type: 'subscribed', streamId }, + _meta: { runtimeId: 'host' } + }) + const output = (streamId: number) => + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 7, + payload: new TextEncoder().encode('hi') + }) + + it('renumbers host stream ids in results and terminal frame headers into the desktop space', () => { + const relay = replies() + relay.noteForwarded('s1') + relay.noteForwarded('s2') + // Both hosts allocate from 1; the phone must see distinct ids. + expect(JSON.parse(relay.text(subscribed('s1', 1))!).result.streamId).toBe(100) + expect(JSON.parse(relay.text(subscribed('s2', 2))!).result.streamId).toBe(101) + const frame = decodeTerminalStreamFrame(relay.binary(output(2))!) + expect(frame).toMatchObject({ streamId: 101, seq: 7, opcode: TerminalStreamOpcode.Output }) + expect(new TextDecoder().decode(frame!.payload)).toBe('hi') + }) + + it('drops terminal frames for streams the phone was never told of and passes other binary untouched', () => { + const relay = replies() + expect(relay.binary(output(9))).toBeNull() + const screencast = encodeBrowserScreencastFrame({ + opcode: BrowserScreencastOpcode.Frame, + seq: 1, + format: 'jpeg', + metadata: { deviceWidth: 1 }, + image: new Uint8Array([1, 2, 3]) + }) + expect(relay.binary(screencast)).toBe(screencast) + }) + + it('passes non-stream replies through byte for byte and forgets a stream once it ends', () => { + const relay = replies() + relay.noteForwarded('s1') + relay.noteForwarded('q') + const plain = '{"id":"q","ok":true,"result":{"x":1},"_meta":{"runtimeId":"h"}}' + expect(relay.text(plain)).toBe(plain) + expect(relay.text('{"_keepalive":true}')).toBe('{"_keepalive":true}') + relay.text(subscribed('s1', 1)) + relay.text( + JSON.stringify({ id: 's1', ok: true, streaming: true, result: { type: 'end' }, _meta: {} }) + ) + expect(relay.binary(output(1))).toBeNull() + expect(relay.takeOpenRequestIds()).toEqual([]) + }) + + it('reports unfinished requests, and swallows replies to requests the relay sent itself', () => { + const relay = replies() + relay.noteForwarded('open') + relay.noteForwarded('mine', { swallowReplies: true }) + expect(relay.text('{"id":"mine","ok":true,"result":{},"_meta":{"runtimeId":"h"}}')).toBeNull() + relay.noteForwarded('mine-2', { swallowReplies: true }) + expect(relay.takeOpenRequestIds()).toEqual(['open']) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts new file mode 100644 index 000000000000..e02301450fe9 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/relayed-phone-frames.ts @@ -0,0 +1,117 @@ +import { + readTerminalStreamFrameId, + writeTerminalStreamFrameId +} from '../../../shared/terminal-stream-protocol' + +const STRIPPED_ENVELOPE_FIELDS = new Set(['deviceToken', 'executionHost']) + +// Why: the phone stamps its desktop token into params (client.id, mobileClient.id, `${terminal}:${token}` +// subscription ids). A field list would rot silently, so every occurrence in the text is swapped. +export function rewriteRelayedPhoneRequest( + frame: string, + phoneDesktopToken: string, + phoneHostToken: string, + requestId?: string +): string { + const parsed: unknown = JSON.parse(frame.split(phoneDesktopToken).join(phoneHostToken)) + if (!isRecord(parsed)) { + throw new Error('relayed_phone_frame_not_an_object') + } + // Why: the host binds identity to the authenticated socket; the routing target is the desktop's only. + const forwarded = Object.fromEntries( + Object.entries(parsed).filter(([field]) => !STRIPPED_ENVELOPE_FIELDS.has(field)) + ) + return JSON.stringify(requestId === undefined ? forwarded : { ...forwarded, id: requestId }) +} + +/** + * The host's replies on one relayed socket, passed through except for stream ids. Why renumber: + * every host allocates terminal stream ids from 1, and the phone routes binary frames by id + * across everything its desktop socket carries. + */ +export class RelayedPhoneReplies { + // Why swallow: requests the relay sends on its own behalf have no phone waiting on them. + private readonly openRequests = new Map() + private readonly desktopStreamIds = new Map() + + constructor(private readonly allocateStreamId: () => number) {} + + noteForwarded(requestId: string, options: { swallowReplies?: boolean } = {}): void { + this.openRequests.set(requestId, { + hostStreamIds: [], + swallow: options.swallowReplies === true + }) + } + + /** Request ids still waiting on the host; the caller fails them when the socket ends. */ + takeOpenRequestIds(): string[] { + const ids = [...this.openRequests].filter(([, open]) => !open.swallow).map(([id]) => id) + this.openRequests.clear() + this.desktopStreamIds.clear() + return ids + } + + /** The frame to hand the phone, or null to drop it. */ + text(plaintext: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(plaintext) + } catch { + return plaintext + } + if (!isRecord(parsed) || typeof parsed.id !== 'string') { + return plaintext + } + const open = this.openRequests.get(parsed.id) + const result = parsed.result + let renumbered = false + if (open && isRecord(result) && typeof result.streamId === 'number') { + result.streamId = this.desktopStreamId(result.streamId, open.hostStreamIds) + renumbered = true + } + // Why bounded: a stream ended by its own unsubscribe sends no 'end'; it stays until the link closes. + if (open && (parsed.streaming !== true || (isRecord(result) && result.type === 'end'))) { + this.finish(parsed.id, open.hostStreamIds) + } + if (open?.swallow) { + return null + } + return renumbered ? JSON.stringify(parsed) : plaintext + } + + /** The frame to hand the phone, or null to drop a terminal frame for a stream it was never told of. */ + binary(bytes: Uint8Array): Uint8Array | null { + const hostStreamId = readTerminalStreamFrameId(bytes) + if (hostStreamId === null) { + return bytes + } + const desktopStreamId = this.desktopStreamIds.get(hostStreamId) + if (desktopStreamId === undefined) { + return null + } + writeTerminalStreamFrameId(bytes, desktopStreamId) + return bytes + } + + private desktopStreamId(hostStreamId: number, requestStreamIds: number[]): number { + const known = this.desktopStreamIds.get(hostStreamId) + if (known !== undefined) { + return known + } + const allocated = this.allocateStreamId() + this.desktopStreamIds.set(hostStreamId, allocated) + requestStreamIds.push(hostStreamId) + return allocated + } + + private finish(requestId: string, hostStreamIds: number[]): void { + this.openRequests.delete(requestId) + for (const hostStreamId of hostStreamIds) { + this.desktopStreamIds.delete(hostStreamId) + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.test.ts b/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.test.ts new file mode 100644 index 000000000000..fffda55e8abb --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { ALL_RPC_METHODS } from '../rpc/methods' +import { MOBILE_RPC_METHOD_ALLOWLIST } from '../runtime-rpc/runtime-rpc-mobile-method-allowlist' +import { MOBILE_RPC_METHOD_ROUTES } from '../runtime-rpc/runtime-rpc-mobile-method-routing' +import { RELAYED_STREAM_CARRIERS } from './relayed-stream-carriers' + +describe('relayed stream carrier census', () => { + const relayableStreams: string[] = ALL_RPC_METHODS.filter( + (method) => + 'stream' in method && + MOBILE_RPC_METHOD_ALLOWLIST.has(method.name) && + MOBILE_RPC_METHOD_ROUTES.get(method.name) === 'execution-host' + ).map((method) => method.name) + + it('classifies how every relayable stream names itself', () => { + expect(relayableStreams.filter((name) => !RELAYED_STREAM_CARRIERS.has(name))).toEqual([]) + }) + + it('classifies nothing that is not a relayable stream', () => { + expect( + [...RELAYED_STREAM_CARRIERS.keys()].filter((name) => !relayableStreams.includes(name)) + ).toEqual([]) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts b/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts new file mode 100644 index 000000000000..da3ef79f5eea --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts @@ -0,0 +1,19 @@ +/** + * How each streaming method a phone can relay names its stream. `terminal-stream-id`: the host + * allocates ids in `result.streamId` and terminal binary headers, renumbered by RelayedPhoneReplies. + * `none`: replies carry no host-allocated id (the phone holds one screencast at a time). + * `phone-binary-frames`: the client drives the stream with binary frames, which the relay never + * forwards, so the call is refused rather than opened dead. The census test fails on a new stream. + */ +export type RelayedStreamCarrier = 'terminal-stream-id' | 'none' | 'phone-binary-frames' + +export const RELAYED_STREAM_CARRIERS: ReadonlyMap = new Map([ + ['terminal.subscribe', 'terminal-stream-id'], + ['terminal.multiplex', 'phone-binary-frames'], + ['browser.screencast', 'none'], + ['agentSession.subscribe', 'none'], + ['agentSession.subscribeStatus', 'none'], + ['nativeChat.subscribe', 'none'], + ['session.tabs.subscribe', 'none'], + ['session.tabs.subscribeAll', 'none'] +]) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 86226f83522f..f58b10313bba 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -60,6 +60,8 @@ export type RpcRequest = { orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence // Why unknown: untrusted wire input; runtime-source-admission.ts validates it. expectedRuntimeSource?: unknown + // Why unknown: phone-supplied routing target (`runtime:`), validated where it is read. + executionHost?: unknown } export type LegacyCoordinatorAuthorityProof = Readonly<{ diff --git a/src/main/runtime/runtime-rpc-mobile-legacy-socket-relay-refusal.test.ts b/src/main/runtime/runtime-rpc-mobile-legacy-socket-relay-refusal.test.ts new file mode 100644 index 000000000000..331a06e628ac --- /dev/null +++ b/src/main/runtime/runtime-rpc-mobile-legacy-socket-relay-refusal.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { DeviceRegistry } from './device-registry' +import { createMobileRpcSurfaceRuntime } from './runtime-rpc-mobile-method-allowlist-fixtures' + +describe('a server-targeted phone call on a legacy (non-E2EE) socket', () => { + it('is refused, never run on this desktop', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-legacy-relay-')) + const { runtime, mocks } = createMobileRpcSurfaceRuntime() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('phone', 'mobile') + const replies: Record[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_1', + method: 'terminal.read', + executionHost: 'runtime:env-1', + deviceToken: device.token, + params: { terminal: 'terminal-1' } + }), + (response) => { + const parsed: Record = JSON.parse(response) + replies.push(parsed) + }, + () => {} + ) + expect(mocks.readTerminal).not.toHaveBeenCalled() + expect(replies).toMatchObject([ + { + ok: false, + error: { + code: 'remote_runtime_unavailable', + message: 'Servers are reachable only over an encrypted pairing' + } + } + ]) + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 88c711ebd709..3b01befecd5b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -261,6 +261,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { return } this.abortWebSocketDispatches(socket.ws) + this.mobileDesktopRelay?.closePhoneConnection(socket.connectionId) // Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports. this.runtime.cleanupSubscriptionsForConnection(socket.connectionId) this.runtime.cancelMobileDictationForConnection(socket.connectionId) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts index 6ceb45937d8f..f30ceee365a1 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -1,4 +1,4 @@ -/** The structured agent-session methods a phone may call; spread into the mobile allowlist. */ +/** The structured agent-session methods a phone may call; spread into the execution-host routes. */ export const MOBILE_AGENT_SESSION_RPC_METHODS = [ 'agentSession.createSupport', 'agentSession.create', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts index bf5eb52b4cc4..73c5f243b7db 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts @@ -10,7 +10,11 @@ export class RuntimeRpcMobileDevices extends RuntimeRpcRequestAdmission { private onPushUnregisterQueued?: () => void async revokeMobileDevice(deviceId: string): Promise { - return this.revokeMobileDeviceNow(deviceId) + const revoked = this.revokeMobileDeviceNow(deviceId) + if (revoked) { + this.mobileDesktopRelay?.phonesChanged() + } + return revoked } private revokeMobileDeviceNow(deviceId: string): boolean { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index c6e43823a808..10a3b6a77ac8 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -1,284 +1,4 @@ -import { MOBILE_AGENT_SESSION_RPC_METHODS } from './runtime-rpc-mobile-agent-session-methods' +import { MOBILE_RPC_METHOD_ROUTES } from './runtime-rpc-mobile-method-routing' -export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ - 'accounts.list', - 'accounts.consumeCodexResetCredit', - 'accounts.selectClaude', - 'accounts.selectCodex', - 'accounts.selectCodexForTarget', - 'accounts.subscribe', - 'accounts.unsubscribe', - 'agent.launch', - 'agent.launchReplay', - 'aiVault.listSessions', - 'aiVault.searchSessions', - 'aiVault.searchStatus', - 'aiVault.resolveSessionTitles', - 'aiVault.prepareSessionResume', - 'browser.back', - 'browser.dialogAccept', - 'browser.dialogDismiss', - 'browser.forward', - 'browser.goto', - 'browser.keyboardInsertText', - 'browser.keypress', - 'browser.mouseDown', - 'browser.mouseClick', - 'browser.mouseMove', - 'browser.mouseUp', - 'browser.mouseWheel', - 'browser.reload', - 'browser.screencast', - 'browser.screencast.unsubscribe', - 'browser.tabCreate', - 'browser.viewport', - 'clipboard.abortImageUpload', - 'clipboard.appendImageUploadChunk', - 'clipboard.commitImageUpload', - 'clipboard.saveImageAsTempFile', - 'clipboard.startImageUpload', - 'diagnostics.memory', - 'files.browseServerDir', - 'files.createFile', - 'files.list', - 'files.open', - 'files.openDiff', - 'files.read', - 'files.readDocPreview', - 'files.readChunk', - 'files.readDir', - 'files.readPreview', - 'files.readTerminalArtifact', - 'files.readTerminalArtifactPreview', - 'files.resolveTerminalPath', - 'files.searchPaths', - 'files.stat', - 'files.writeTerminalArtifact', - 'folderWorkspace.list', - 'git.abortMerge', - 'git.abortRebase', - 'git.bulkStage', - 'git.bulkUnstage', - 'git.branchCompare', - 'git.branchDiff', - 'git.cancelGenerateCommitMessage', - 'git.cancelGeneratePullRequestFields', - 'git.checkout', - 'git.commit', - 'git.commitCompare', - 'git.commitDiff', - 'git.discard', - 'git.discoverCommitMessageModels', - 'git.diff', - 'git.fetch', - 'git.forkSync', - 'git.fastForward', - 'git.generateCommitMessage', - 'git.generatePullRequestFields', - 'git.history', - 'git.localBranches', - 'git.pull', - 'git.push', - 'git.rebaseFromBase', - 'git.stage', - 'git.status', - 'git.unstage', - 'git.upstreamStatus', - 'github.createIssue', - 'github.addIssueComment', - 'github.addPRReviewComment', - 'github.addPRReviewCommentReply', - 'github.countWorkItems', - 'github.listAssignableUsers', - 'github.listLabels', - 'github.listWorkItems', - 'github.mergePR', - 'github.setPRAutoMerge', - 'github.requestPRReviewers', - 'github.removePRReviewers', - 'github.project.listAccessible', - 'github.project.listAssignableUsersBySlug', - 'github.project.listIssueTypesBySlug', - 'github.project.listLabelsBySlug', - 'github.project.listViews', - 'github.project.resolveRef', - 'github.project.addIssueCommentBySlug', - 'github.project.updateIssueCommentBySlug', - 'github.project.deleteIssueCommentBySlug', - 'github.project.clearItemField', - 'github.project.updateIssueBySlug', - 'github.project.updateIssueTypeBySlug', - 'github.project.updateItemField', - 'github.project.updatePullRequestBySlug', - 'github.project.viewTable', - 'github.project.workItemDetailsBySlug', - 'github.prForBranch', - 'github.prFileContents', - 'github.prChecks', - 'github.prCheckDetails', - 'github.rerunPRChecks', - 'github.resolveReviewThread', - 'github.setPRFileViewed', - 'github.updateIssue', - 'github.updatePR', - 'github.updatePRTitle', - 'github.updatePRState', - 'github.repoSlug', - 'github.workItem', - // Cross-repo lookup: lets the mobile Smart picker resolve a pasted github.com URL for a different repo. - 'github.workItemByOwnerRepo', - 'github.workItemDetails', - 'gitlab.createIssue', - 'gitlab.addIssueComment', - 'gitlab.addMRComment', - 'gitlab.listWorkItems', - // Mobile Smart picker: resolve a pasted GitLab URL to an exact issue/MR (MR listing reuses gitlab.listWorkItems). - 'gitlab.workItemByPath', - 'gitlab.mergeMR', - 'gitlab.resolveMRDiscussion', - 'gitlab.todos', - 'gitlab.updateIssue', - 'gitlab.updateMR', - 'gitlab.updateMRState', - 'gitlab.workItemDetails', - 'host.gitBash.isAvailable', - 'host.platform', - 'host.pwsh.isAvailable', - 'host.wsl.isAvailable', - 'host.wsl.listDistros', - 'hostedReview.create', - 'hostedReview.createStacked', - 'hostedReview.forBranch', - 'hostedReview.getCreationEligibility', - 'linear.getCustomView', - 'linear.getIssue', - 'linear.getProject', - 'linear.agentSearchIssues', - 'linear.issueContext', - 'linear.resolveCurrentIssue', - 'linear.addIssueComment', - 'linear.connect', - 'linear.createIssue', - 'linear.createProject', - 'linear.issueComments', - 'linear.listCustomViewIssues', - 'linear.listCustomViewProjects', - 'linear.listCustomViews', - 'linear.listIssues', - 'linear.mcpListIssues', - 'linear.listProjectIssues', - 'linear.listProjects', - 'linear.teamLabels', - 'linear.teamMembers', - 'linear.listTeams', - 'linear.searchIssues', - 'linear.selectWorkspace', - 'linear.status', - 'linear.teamStates', - 'linear.updateIssue', - 'markdown.readTab', - 'markdown.saveTab', - 'mobileWeb.bundle.chunk', - 'mobileWeb.bundle.manifest', - 'mobileWeb.bundle.range', - 'notifications.getMissedSince', - 'notifications.registerPush', - 'notifications.subscribe', - 'notifications.testPush', - 'notifications.unregisterPush', - 'notifications.unsubscribe', - 'pairing.getEndpoints', - 'pairing.provisionRelay', - 'preflight.check', - 'preflight.detectAgents', - 'preflight.detectRemoteAgents', - 'projectGroup.list', - 'repo.baseRefDefault', - 'repo.gitAvailable', - 'repo.hooks', - 'repo.list', - 'repo.saveSparsePreset', - 'repo.searchRefs', - 'repo.sparsePresets', - 'repo.update', - 'runtime.clientCapabilities.update', - 'runtime.clientEvents.subscribe', - 'runtime.clientEvents.unsubscribe', - 'session.tabs.activate', - 'session.tabs.close', - 'session.tabs.closeLifecycle', - 'session.tabs.createTerminal', - 'session.tabs.list', - 'session.tabs.listAll', - 'session.tabs.move', - 'session.tabs.subscribe', - 'session.tabs.subscribeAll', - 'session.tabs.unsubscribe', - 'session.tabs.unsubscribeAll', - ...MOBILE_AGENT_SESSION_RPC_METHODS, - 'nativeChat.readSession', - 'nativeChat.subscribe', - 'nativeChat.unsubscribe', - 'settings.get', - 'settings.getTerminalQuickCommands', - 'settings.mutateNativeChatSessionOptions', - 'settings.update', - 'settings.updateTerminalQuickCommands', - 'ssh.connect', - 'ssh.getState', - 'ssh.listRemovedTargetLabels', - 'ssh.listTargets', - 'ssh.listTargetSummaries', - 'speech.dictation.cancel', - 'speech.dictation.chunk', - 'speech.dictation.finish', - 'speech.dictation.setup', - 'speech.dictation.start', - 'speech.models.delete', - 'speech.models.download', - 'speech.models.list', - 'stats.summary', - 'status.get', - 'agentTeams.prepareLaunch', - 'agentTeams.tmuxCompat', - // Why: the phone reports a takeover out of band, the same signal the desktop renderer sends. - 'orchestration.workerTerminalUserInput', - 'terminal.clearBuffer', - 'terminal.close', - 'terminal.closeAll', - 'terminal.closeTab', - 'terminal.create', - 'terminal.createAgentSession', - 'terminal.ensureAgentSession', - 'terminal.focus', - 'terminal.agentStatus', - 'terminal.adoptOrphans', - 'terminal.getAutoRestoreFit', - 'terminal.isRunningAgent', - 'terminal.list', - 'terminal.multiplex', - 'terminal.read', - 'terminal.rename', - 'terminal.send', - 'terminal.setAutoRestoreFit', - 'terminal.setDisplayMode', - 'terminal.subscribe', - 'terminal.unsubscribe', - 'terminal.updateViewport', - 'terminal.wait', - 'ui.get', - 'ui.recordFeatureInteraction', - 'ui.set', - 'worktree.activate', - 'worktree.create', - 'worktree.forceDeleteBranch', - 'worktree.listRetiredNames', - 'worktree.prefetchCreateBase', - 'worktree.ps', - 'worktree.show', - 'worktree.resolveMrBase', - 'worktree.resolvePrBase', - 'worktree.rm', - 'worktree.set', - 'worktree.sleep' -]) +// Why: the routing table is the one list of phone methods, so allowlisting a method means routing it. +export const MOBILE_RPC_METHOD_ALLOWLIST = new Set(MOBILE_RPC_METHOD_ROUTES.keys()) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts new file mode 100644 index 000000000000..18a9de170a46 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { + EXECUTION_HOST_METHODS, + MOBILE_RPC_METHOD_ROUTES +} from './runtime-rpc-mobile-method-routing' +import { PAIRED_DESKTOP_METHODS } from './runtime-rpc-mobile-paired-desktop-methods' + +describe('mobile RPC method routing census', () => { + it('tags every routed method exactly once', () => { + expect(PAIRED_DESKTOP_METHODS.length + EXECUTION_HOST_METHODS.length).toBe( + MOBILE_RPC_METHOD_ROUTES.size + ) + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts new file mode 100644 index 000000000000..0f6a506a925a --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts @@ -0,0 +1,204 @@ +import { MOBILE_AGENT_SESSION_RPC_METHODS } from './runtime-rpc-mobile-agent-session-methods' +import { PAIRED_DESKTOP_METHODS } from './runtime-rpc-mobile-paired-desktop-methods' + +// Why: a relaying Mac sends `execution-host` calls to the host named by the call's target (itself when +// local) and keeps `paired-desktop` calls. A route is what allowlists a phone method. +export type MobileRpcMethodRoute = 'execution-host' | 'paired-desktop' + +export const EXECUTION_HOST_METHODS = [ + 'agent.launch', + 'agent.launchReplay', + 'agentTeams.prepareLaunch', + 'agentTeams.tmuxCompat', + 'browser.back', + 'browser.dialogAccept', + 'browser.dialogDismiss', + 'browser.forward', + 'browser.goto', + 'browser.keyboardInsertText', + 'browser.keypress', + 'browser.mouseClick', + 'browser.mouseDown', + 'browser.mouseMove', + 'browser.mouseUp', + 'browser.mouseWheel', + 'browser.reload', + 'browser.screencast', + 'browser.screencast.unsubscribe', + 'browser.tabCreate', + 'browser.viewport', + // Pasted images must land on the host whose terminal reads the path. + 'clipboard.abortImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload', + 'clipboard.saveImageAsTempFile', + 'clipboard.startImageUpload', + 'files.browseServerDir', + 'files.createFile', + 'files.list', + 'files.open', + 'files.openDiff', + 'files.read', + 'files.readChunk', + 'files.readDir', + 'files.readDocPreview', + 'files.readPreview', + 'files.readTerminalArtifact', + 'files.readTerminalArtifactPreview', + 'files.resolveTerminalPath', + 'files.searchPaths', + 'files.stat', + 'files.writeTerminalArtifact', + 'folderWorkspace.list', + 'git.abortMerge', + 'git.abortRebase', + 'git.branchCompare', + 'git.branchDiff', + 'git.bulkStage', + 'git.bulkUnstage', + 'git.cancelGenerateCommitMessage', + 'git.cancelGeneratePullRequestFields', + 'git.checkout', + 'git.commit', + 'git.commitCompare', + 'git.commitDiff', + 'git.diff', + 'git.discard', + 'git.discoverCommitMessageModels', + 'git.fastForward', + 'git.fetch', + 'git.forkSync', + 'git.generateCommitMessage', + 'git.generatePullRequestFields', + 'git.history', + 'git.localBranches', + 'git.pull', + 'git.push', + 'git.rebaseFromBase', + 'git.stage', + 'git.status', + 'git.unstage', + 'git.upstreamStatus', + // Repo-scoped provider calls run where the desktop runs them: on the repo's host, with its login. + 'github.addIssueComment', + 'github.addPRReviewComment', + 'github.addPRReviewCommentReply', + 'github.countWorkItems', + 'github.createIssue', + 'github.listAssignableUsers', + 'github.listLabels', + 'github.listWorkItems', + 'github.mergePR', + 'github.prCheckDetails', + 'github.prChecks', + 'github.prFileContents', + 'github.prForBranch', + 'github.removePRReviewers', + 'github.repoSlug', + 'github.requestPRReviewers', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRAutoMerge', + 'github.setPRFileViewed', + 'github.updateIssue', + 'github.updatePR', + 'github.updatePRState', + 'github.updatePRTitle', + 'github.workItem', + // Cross-repo lookup: lets the mobile Smart picker resolve a pasted github.com URL for a different repo. + 'github.workItemByOwnerRepo', + 'github.workItemDetails', + 'gitlab.addIssueComment', + 'gitlab.addMRComment', + 'gitlab.createIssue', + 'gitlab.listWorkItems', + 'gitlab.mergeMR', + 'gitlab.resolveMRDiscussion', + 'gitlab.todos', + 'gitlab.updateIssue', + 'gitlab.updateMR', + 'gitlab.updateMRState', + // Mobile Smart picker: resolve a pasted GitLab URL to an exact issue/MR (MR listing reuses gitlab.listWorkItems). + 'gitlab.workItemByPath', + 'gitlab.workItemDetails', + // Shell and agent availability are facts about the machine that spawns the process. + 'host.gitBash.isAvailable', + 'host.platform', + 'host.pwsh.isAvailable', + 'host.wsl.isAvailable', + 'host.wsl.listDistros', + 'preflight.check', + 'preflight.detectAgents', + 'preflight.detectRemoteAgents', + 'hostedReview.create', + 'hostedReview.createStacked', + 'hostedReview.forBranch', + 'hostedReview.getCreationEligibility', + 'linear.resolveCurrentIssue', + 'markdown.readTab', + 'markdown.saveTab', + 'nativeChat.readSession', + 'nativeChat.subscribe', + 'nativeChat.unsubscribe', + // Why: the phone reports a takeover out of band, the same signal the desktop renderer sends. + 'orchestration.workerTerminalUserInput', + 'projectGroup.list', + 'repo.baseRefDefault', + 'repo.gitAvailable', + 'repo.hooks', + 'repo.list', + 'repo.saveSparsePreset', + 'repo.searchRefs', + 'repo.sparsePresets', + 'repo.update', + 'session.tabs.activate', + 'session.tabs.close', + 'session.tabs.closeLifecycle', + 'session.tabs.createTerminal', + 'session.tabs.list', + 'session.tabs.listAll', + 'session.tabs.move', + 'session.tabs.subscribe', + 'session.tabs.subscribeAll', + 'session.tabs.unsubscribe', + 'session.tabs.unsubscribeAll', + ...MOBILE_AGENT_SESSION_RPC_METHODS, + 'terminal.adoptOrphans', + 'terminal.agentStatus', + 'terminal.clearBuffer', + 'terminal.close', + 'terminal.closeAll', + 'terminal.closeTab', + 'terminal.create', + 'terminal.createAgentSession', + 'terminal.ensureAgentSession', + 'terminal.focus', + 'terminal.isRunningAgent', + 'terminal.list', + 'terminal.multiplex', + 'terminal.read', + 'terminal.rename', + 'terminal.send', + 'terminal.setDisplayMode', + 'terminal.subscribe', + 'terminal.unsubscribe', + 'terminal.updateViewport', + 'terminal.wait', + 'worktree.activate', + 'worktree.create', + 'worktree.forceDeleteBranch', + 'worktree.listRetiredNames', + 'worktree.prefetchCreateBase', + 'worktree.ps', + 'worktree.resolveMrBase', + 'worktree.resolvePrBase', + 'worktree.rm', + 'worktree.set', + 'worktree.show', + 'worktree.sleep' +] as const + +export const MOBILE_RPC_METHOD_ROUTES: ReadonlyMap = new Map([ + ...PAIRED_DESKTOP_METHODS.map((method) => [method, 'paired-desktop'] as const), + ...EXECUTION_HOST_METHODS.map((method) => [method, 'execution-host'] as const) +]) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts new file mode 100644 index 000000000000..14ac2c5dcf79 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -0,0 +1,103 @@ +/** Phone methods the relaying Mac always answers itself, even when the call names a server. */ +export const PAIRED_DESKTOP_METHODS = [ + // Device and pairing concerns of the phone's own pairing. + 'mobileWeb.bundle.chunk', + 'mobileWeb.bundle.manifest', + 'mobileWeb.bundle.range', + 'notifications.getMissedSince', + 'notifications.registerPush', + 'notifications.subscribe', + 'notifications.testPush', + 'notifications.unregisterPush', + 'notifications.unsubscribe', + 'pairing.getEndpoints', + 'pairing.provisionRelay', + 'speech.dictation.cancel', + 'speech.dictation.chunk', + 'speech.dictation.finish', + 'speech.dictation.setup', + 'speech.dictation.start', + 'speech.models.delete', + 'speech.models.download', + 'speech.models.list', + // The Mac also forwards it to every open server connection of that phone. + 'runtime.clientCapabilities.update', + // The Mac republishes host changes it already observes. + 'runtime.clientEvents.subscribe', + 'runtime.clientEvents.unsubscribe', + 'status.get', + 'diagnostics.memory', + 'stats.summary', + 'ui.get', + 'ui.recordFeatureInteraction', + 'ui.set', + // The desktop's accounts pane, Linear and project pickers target its active runtime, not a workspace's host. + 'accounts.consumeCodexResetCredit', + 'accounts.list', + 'accounts.selectClaude', + 'accounts.selectCodex', + 'accounts.selectCodexForTarget', + 'accounts.subscribe', + 'accounts.unsubscribe', + 'linear.addIssueComment', + 'linear.agentSearchIssues', + 'linear.connect', + 'linear.createIssue', + 'linear.createProject', + 'linear.getCustomView', + 'linear.getIssue', + 'linear.getProject', + 'linear.issueComments', + 'linear.issueContext', + 'linear.listCustomViewIssues', + 'linear.listCustomViewProjects', + 'linear.listCustomViews', + 'linear.listIssues', + 'linear.listProjectIssues', + 'linear.listProjects', + 'linear.listTeams', + 'linear.mcpListIssues', + 'linear.searchIssues', + 'linear.selectWorkspace', + 'linear.status', + 'linear.teamLabels', + 'linear.teamMembers', + 'linear.teamStates', + 'linear.updateIssue', + 'github.project.addIssueCommentBySlug', + 'github.project.clearItemField', + 'github.project.deleteIssueCommentBySlug', + 'github.project.listAccessible', + 'github.project.listAssignableUsersBySlug', + 'github.project.listIssueTypesBySlug', + 'github.project.listLabelsBySlug', + 'github.project.listViews', + 'github.project.resolveRef', + 'github.project.updateIssueBySlug', + 'github.project.updateIssueCommentBySlug', + 'github.project.updateIssueTypeBySlug', + 'github.project.updateItemField', + 'github.project.updatePullRequestBySlug', + 'github.project.viewTable', + 'github.project.workItemDetailsBySlug', + // Desktop main federates session history across hosts itself (executionHostId / scope params). + 'aiVault.listSessions', + 'aiVault.prepareSessionResume', + 'aiVault.resolveSessionTitles', + 'aiVault.searchSessions', + 'aiVault.searchStatus', + // User preferences live on the Mac; the phone's settings screens mirror the desktop's. + 'settings.get', + 'settings.getTerminalQuickCommands', + 'settings.mutateNativeChatSessionOptions', + 'settings.update', + 'settings.updateTerminalQuickCommands', + 'terminal.getAutoRestoreFit', + 'terminal.setAutoRestoreFit', + // SSH targets are configured on the Mac. + 'ssh.connect', + 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargetSummaries', + 'ssh.listTargets' +] as const diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7c60b590effb..13538acc18d3 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -8,6 +8,10 @@ import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairi import type { RuntimePairingReach } from '../../../shared/runtime-pairing-reach' import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint' import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure' +import { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay' +import type { MobileDesktopRelayHosts } from '../mobile-desktop-relay/mobile-desktop-relay-hosts' +import { DELEGATED_PHONE_NAME_MAX_CHARS } from '../../../shared/delegated-mobile-device-contract' +import { allocateTerminalSubscriptionStreamId } from '../rpc/methods/terminal/terminal-subscription-stream-id' import { createWebClientUrl, DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE, @@ -84,6 +88,32 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayPairingProviderInstaller = install } + setMobileDesktopRelayHosts(hosts: MobileDesktopRelayHosts | null): void { + this.mobileDesktopRelay?.dispose() + this.mobileDesktopRelay = hosts + ? new MobileDesktopRelay({ + hosts, + listPhones: () => this.listRelayedPhones(), + // Why: one allocator with local streams, so relayed ids never collide on the phone's socket. + allocateStreamId: allocateTerminalSubscriptionStreamId + }) + : null + } + + private listRelayedPhones(): { phoneKey: string; name: string }[] { + const computerName = this.runtime.readMachineName() + return (this.deviceRegistry?.listDevices() ?? []) + .filter( + // Why: phones another desktop relays here are that desktop's to relay, not ours. + (device) => + device.scope === 'mobile' && device.lastSeenAt > 0 && device.parentDeviceId === undefined + ) + .map((device) => ({ + phoneKey: device.deviceId, + name: `${device.name} via ${computerName}`.slice(0, DELEGATED_PHONE_NAME_MAX_CHARS) + })) + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 975085275ffa..64b44e58733f 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -10,6 +10,7 @@ import type { E2EEKeypair } from '../e2ee-keypair' import type { RuntimeHostDescriptor } from '../../../shared/runtime-host-descriptor' import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import type { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay' import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox' import { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { RuntimeBinaryMessageRouter } from '../runtime-binary-message-router' @@ -72,6 +73,8 @@ export class RuntimeRpcState { // transports under the SAME wiring (see ensureMobileSocketWiring) instead of orphaning relay sockets. protected detachWebSocketWiring: (() => void) | null = null protected mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + // Why: only a desktop with configured servers relays; headless serve leaves this unset. + protected mobileDesktopRelay: MobileDesktopRelay | null = null // Why: lets an automatic mint install a provider whose launch-time construction failed. protected mobileRelayPairingProviderInstaller: (() => Promise) | null = null protected mobileRelayPairingOfferQueue: Promise = Promise.resolve() diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index c3809ab190af..91236b644374 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -13,6 +13,9 @@ import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' import type { RpcBinarySender } from '../rpc/rpc-binary-sender' import type { RpcCallerScope } from '../rpc/rpc-caller-scope' import { limitRuntimeRpcReplySize } from './runtime-rpc-reply-size-limit' +import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist' +import { MOBILE_RPC_METHOD_ROUTES } from './runtime-rpc-mobile-method-routing' +import { parseExecutionHostId } from '../../../shared/execution-host' // Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. function injectDeviceScope(response: string, scope: DeviceScope): string { @@ -29,6 +32,71 @@ function injectDeviceScope(response: string, scope: DeviceScope): string { } export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { + // Why: a phone names a server's workspace with `executionHost`; only configured servers are reached, + // and a target the desktop cannot reach is an error, never a silent local run. + private relayMobileRequest( + request: RpcRequest, + rawMessage: string, + reply: (response: string) => void, + sendBinary: RpcBinarySender, + socket: AuthenticatedMobileSocket | undefined + ): boolean { + const { executionHost } = request + if (executionHost === undefined) { + return false + } + const target = parseExecutionHostId(typeof executionHost === 'string' ? executionHost : null) + if (target?.kind === 'local' || target?.kind === 'ssh') { + return false + } + // Why: the phone tags every call on a workspace screen; only this Mac's routing table decides. + if (target && MOBILE_RPC_METHOD_ROUTES.get(request.method) !== 'execution-host') { + return false + } + if (!target) { + reply( + JSON.stringify(this.buildError(request.id, 'invalid_argument', 'Invalid executionHost')) + ) + } else if (!socket) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'remote_runtime_unavailable', + 'Servers are reachable only over an encrypted pairing' + ) + ) + ) + } else if (!this.mobileDesktopRelay) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'remote_runtime_unavailable', + 'This computer does not relay to servers' + ) + ) + ) + } else { + this.mobileDesktopRelay.forward( + { + connectionId: socket.connectionId, + deviceId: socket.device.deviceId, + deviceToken: socket.device.deviceToken, + clientCapabilities: () => socket.clientCapabilities, + reply, + sendBinary: (bytes) => { + sendBinary(bytes) + } + }, + target.environmentId, + request, + rawMessage + ) + } + return true + } + // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. protected async handleWebSocketMessage( rawMessage: string, @@ -81,6 +149,20 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { ? { kind: 'mobile', delegated: device.parentDeviceId !== undefined } : { kind: 'runtime-paired', grants: device.grants ?? [] } + // Why: relayed calls skip this dispatcher's scope check, so only allowlisted methods may relay. + if (device.scope === 'mobile' && MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { + // Why: the phone's open server sockets must see the same capabilities the desktop does. + if (authenticatedSocket && request.method === 'runtime.clientCapabilities.update') { + this.mobileDesktopRelay?.forwardClientCapabilities( + authenticatedSocket.connectionId, + rawMessage + ) + } + if (this.relayMobileRequest(request, rawMessage, reply, sendBinary, authenticatedSocket)) { + return + } + } + // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. if (wsTransport && ws) { wsTransport.setClientId(ws, token) diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts index cfc85d7b75e5..9008145308eb 100644 --- a/src/main/startup/main-process-runtime-launch-activation.test.ts +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -30,6 +30,7 @@ vi.mock('../runtime/runtime-rpc', () => ({ launchOrder.push('rpc-start') }) setOnUnpairedDeviceAuthFailure = vi.fn() + setMobileDesktopRelayHosts = vi.fn() } })) vi.mock('../runtime/headless-runtime-graph', () => ({ publishHeadlessRuntimeGraph: vi.fn() })) @@ -39,6 +40,9 @@ vi.mock('./headless-serve-ssh-registration', () => ({ }) })) vi.mock('../ipc/mobile', () => ({ registerMobileHandlers: vi.fn() })) +vi.mock('../ipc/runtime-environment-mobile-relay-hosts', () => ({ + createRuntimeEnvironmentMobileRelayHosts: vi.fn() +})) vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), registerHeadlessPtyRuntime: vi.fn() diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 80979d6ca549..b8050920f6c4 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -6,6 +6,7 @@ import { } from '../persistence' import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { registerMobileHandlers } from '../ipc/mobile' +import { createRuntimeEnvironmentMobileRelayHosts } from '../ipc/runtime-environment-mobile-relay-hosts' import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' import { LocalPtyProvider } from '../providers/local-pty-provider' import { publishHeadlessRuntimeGraph } from '../runtime/headless-runtime-graph' @@ -94,6 +95,7 @@ function installRuntimeRpc( webClientRoot: getBundledWebClientRoot() }) state.runtimeRpc = runtimeRpc + runtimeRpc.setMobileDesktopRelayHosts(createRuntimeEnvironmentMobileRelayHosts()) registerMobileHandlers(runtimeRpc, { getRelayStatus: getDesktopRelayStatus, consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { diff --git a/src/shared/delegated-mobile-device-contract.ts b/src/shared/delegated-mobile-device-contract.ts index 9a0032eee273..ec248eda5862 100644 --- a/src/shared/delegated-mobile-device-contract.ts +++ b/src/shared/delegated-mobile-device-contract.ts @@ -7,11 +7,12 @@ export const DELEGATED_MOBILE_DEVICE_SYNC_METHOD = 'pairing.delegatedMobileDevic export const DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY = 'pairing.delegated-mobile-devices.v1' satisfies (typeof RUNTIME_CAPABILITIES)[number] export const DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES = 32 +export const DELEGATED_PHONE_NAME_MAX_CHARS = 128 const DelegatedPhoneSchema = z .object({ phoneKey: z.string().min(1).max(128), - name: z.string().min(1).max(128) + name: z.string().min(1).max(DELEGATED_PHONE_NAME_MAX_CHARS) }) .strict() diff --git a/src/shared/mobile-desktop-relay-contract.ts b/src/shared/mobile-desktop-relay-contract.ts new file mode 100644 index 000000000000..1ae407dce6db --- /dev/null +++ b/src/shared/mobile-desktop-relay-contract.ts @@ -0,0 +1,4 @@ +// Why: a phone may put `executionHost` on a request only after its desktop advertises this; an older +// desktop ignores the field and would run the call on itself. Not in RUNTIME_CAPABILITIES yet: the +// phone's host-scoped client ships first (S2), and adding it there is the one line that turns relay on. +export const MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY = 'mobile.desktop-relay.v1' as const diff --git a/src/shared/remote-runtime-passthrough-socket.ts b/src/shared/remote-runtime-passthrough-socket.ts new file mode 100644 index 000000000000..50920fec667d --- /dev/null +++ b/src/shared/remote-runtime-passthrough-socket.ts @@ -0,0 +1,175 @@ +import WebSocket from 'ws' +import type { PairingOffer } from './pairing' +import type { RuntimeCapability } from './protocol-version' +import { decrypt, decryptBytes, encrypt } from './e2ee-crypto' +import { + classifyRemoteRuntimeReadyFrame, + formatRemoteRuntimeCloseMessage, + parseRemoteRuntimeAuthenticatedFrame +} from './remote-runtime-client-handshake' +import { RemoteRuntimeClientError } from './remote-runtime-client-error' +import { serializeRemoteRuntimePayload } from './remote-runtime-memory-limits' +import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' +import { closeRemoteRuntimeSocket } from './remote-runtime-socket-close' +import { + startRemoteRuntimeSocketLiveness, + type RemoteRuntimeSocketLivenessOptions +} from './remote-runtime-socket-liveness' +import { RemoteRuntimeSubscriptionOutbound } from './remote-runtime-subscription-outbound' + +export type RemoteRuntimePassthroughSocket = { + /** Queues one plaintext RPC frame; false when the socket can no longer carry it. */ + send: (plaintext: string) => boolean + close: () => void +} + +export type RemoteRuntimePassthroughCallbacks = { + onText: (plaintext: string) => void + onBinary: (bytes: Uint8Array) => void + /** Called once when an open socket ends for any reason other than `close()`. */ + onClose: (error: RemoteRuntimeClientError) => void +} + +/** + * An authenticated E2EE socket that carries any number of requests and streams and hands every + * decrypted frame back untouched. Why not the subscription transport: it owns one request id per + * socket and fails on any other, while a relay forwards whatever its client multiplexes. + */ +export function openRemoteRuntimePassthroughSocket( + pairing: PairingOffer, + clientCapabilities: readonly RuntimeCapability[], + callbacks: RemoteRuntimePassthroughCallbacks, + options: RemoteRuntimeSocketLivenessOptions & { timeoutMs: number } +): Promise { + return new Promise((resolve, reject) => { + let state: 'awaiting_ready' | 'awaiting_authenticated' | 'ready' | 'ended' = 'awaiting_ready' + const opened = openRemoteRuntimeWebSocket(pairing, { + onTextFrame: (_ws, frame) => onTextFrame(frame), + onBinaryFrame: (_ws, frame) => { + liveness.noteActivity() + const bytes = state === 'ready' ? decryptBytes(frame, sharedKey) : null + if (!bytes) { + end(protocolError('Remote Orca runtime returned an unreadable binary frame.')) + return + } + callbacks.onBinary(bytes) + }, + onError: (_ws, error) => end(error), + onClose: (_ws, code, reason) => + end( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + formatRemoteRuntimeCloseMessage(code, reason) + ) + ), + onPong: () => liveness.noteActivity(), + onPing: () => liveness.noteActivity() + }) + if (!opened.ok) { + reject(opened.error) + return + } + const { ws, sharedKey, cleanup } = opened.socket + const outbound = new RemoteRuntimeSubscriptionOutbound({ fail: (error) => end(error) }) + const timeout = setTimeout( + () => + end( + new RemoteRuntimeClientError( + 'runtime_timeout', + 'Timed out connecting to the remote Orca runtime.' + ) + ), + options.timeoutMs + ) + const liveness = startRemoteRuntimeSocketLiveness({ + ping: () => { + if (ws.readyState === WebSocket.OPEN) { + ws.ping() + } + }, + onDead: () => + end( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime stopped responding; the connection was reset.' + ) + ), + options + }) + + function end(error: RemoteRuntimeClientError | null): void { + if (state === 'ended') { + return + } + const wasReady = state === 'ready' + state = 'ended' + clearTimeout(timeout) + liveness.stop() + outbound.releaseQueues() + outbound.retainSocketMemoryUntilClose(ws) + cleanup() + closeRemoteRuntimeSocket(ws) + if (!wasReady) { + reject(error ?? new RemoteRuntimeClientError('remote_runtime_unavailable', 'Closed.')) + } else if (error) { + callbacks.onClose(error) + } + } + + function protocolError(message: string): RemoteRuntimeClientError { + return new RemoteRuntimeClientError('invalid_runtime_response', message) + } + + function onTextFrame(frame: string): void { + liveness.noteActivity() + if (state === 'awaiting_ready') { + if (classifyRemoteRuntimeReadyFrame(frame) !== 'ready') { + end(protocolError('Remote Orca runtime returned an invalid E2EE handshake frame.')) + return + } + state = 'awaiting_authenticated' + ws.send( + encrypt( + serializeRemoteRuntimePayload({ + type: 'e2ee_auth', + deviceToken: pairing.deviceToken, + clientCapabilities + }), + sharedKey + ) + ) + return + } + const plaintext = decrypt(frame, sharedKey) + if (plaintext === null) { + end(protocolError('Remote Orca runtime returned an undecryptable frame.')) + return + } + if (state === 'ready') { + callbacks.onText(plaintext) + return + } + const authenticated = parseRemoteRuntimeAuthenticatedFrame(plaintext) + if (authenticated.kind !== 'authenticated') { + end( + authenticated.kind === 'rejected' && authenticated.unauthorized + ? new RemoteRuntimeClientError( + 'unauthorized', + 'Remote Orca runtime rejected the pairing token.' + ) + : protocolError('Remote Orca runtime returned an invalid E2EE auth frame.') + ) + return + } + state = 'ready' + clearTimeout(timeout) + resolve({ + send: (plaintext) => + state === 'ready' && ws.readyState === WebSocket.OPEN + ? outbound.enqueueRequest(ws, encrypt(plaintext, sharedKey)) + : false, + close: () => end(null) + }) + } + }) +} diff --git a/src/shared/remote-runtime-request-websocket.test.ts b/src/shared/remote-runtime-request-websocket.test.ts index 0458febd6982..d025671f32fa 100644 --- a/src/shared/remote-runtime-request-websocket.test.ts +++ b/src/shared/remote-runtime-request-websocket.test.ts @@ -2,6 +2,8 @@ import type { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { generateKeyPair, publicKeyToBase64 } from './e2ee-crypto' import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' +import { REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES } from './remote-runtime-memory-limits' +import { WebSocketServer } from 'ws' describe('openRemoteRuntimeWebSocket', () => { it('detaches Orca callback listeners when cleaned up', () => { @@ -39,4 +41,42 @@ describe('openRemoteRuntimeWebSocket', () => { expect(() => socketEvents.emit('error', new Error('late socket error'))).not.toThrow() opened.socket.ws.terminate() }) + + it('closes on a frame larger than any runtime sends', async () => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + server.on('connection', (ws) => + ws.send(Buffer.alloc(REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES + 1)) + ) + await new Promise((resolve) => server.once('listening', resolve)) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + const onError = vi.fn() + const onBinaryFrame = vi.fn() + const closed = new Promise((resolve) => { + const opened = openRemoteRuntimeWebSocket( + { + v: 2, + endpoint: `ws://127.0.0.1:${port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(generateKeyPair().publicKey) + }, + { + onClose: () => resolve(), + onError, + onTextFrame: vi.fn(), + onBinaryFrame + } + ) + if (!opened.ok) { + throw opened.error + } + }) + try { + await closed + expect(onError).toHaveBeenCalledOnce() + expect(onBinaryFrame).not.toHaveBeenCalled() + } finally { + await new Promise((resolve) => server.close(resolve)) + } + }) }) diff --git a/src/shared/remote-runtime-request-websocket.ts b/src/shared/remote-runtime-request-websocket.ts index ca2ddfec60dc..03749478fafa 100644 --- a/src/shared/remote-runtime-request-websocket.ts +++ b/src/shared/remote-runtime-request-websocket.ts @@ -11,6 +11,7 @@ import { remoteRuntimeConnectFailureMessage, remoteRuntimeConnectOptions } from './remote-runtime-connect-bound' +import { REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES } from './remote-runtime-memory-limits' import { invalidRemoteRuntimeResponseError, remoteRuntimeUnavailableError @@ -26,6 +27,8 @@ export type RemoteRuntimeWebSocketCallbacks = { onClose: (ws: WebSocket, code: number, reason: Buffer) => void onError: (ws: WebSocket, error: RemoteRuntimeClientError) => void onTextFrame: (ws: WebSocket, frame: string) => void + // Why optional: request sockets treat a binary frame as a protocol error; a relay forwards it. + onBinaryFrame?: (ws: WebSocket, frame: Uint8Array) => void // Why: protocol-level pongs (and server heartbeat pings) are the liveness // signal for detecting half-open tunnels that never deliver `close` (#7718). onPong?: (ws: WebSocket) => void @@ -64,6 +67,10 @@ export function openRemoteRuntimeWebSocket( } const onClose = (code: number, reason: Buffer): void => callbacks.onClose(ws, code, reason) const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { + if (isBinary && callbacks.onBinaryFrame && Buffer.isBuffer(data)) { + callbacks.onBinaryFrame(ws, new Uint8Array(data)) + return + } if (isBinary) { callbacks.onError( ws, @@ -129,7 +136,14 @@ function createSocket( try { return { ok: true, - ws: new WebSocket(pairing.endpoint, remoteRuntimeConnectOptions(undefined, connectTimeoutMs)), + // Why: no runtime frame exceeds this, so a larger one is a broken peer, not a payload to buffer. + ws: new WebSocket( + pairing.endpoint, + remoteRuntimeConnectOptions( + { maxPayload: REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES }, + connectTimeoutMs + ) + ), keyPair } } catch (error) { diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 52b6c5547842..0b74d7433bd5 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -104,6 +104,23 @@ export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFram } } +/** The stream id in a terminal frame's header, or null when the bytes are not a terminal frame. */ +export function readTerminalStreamFrameId(bytes: Uint8Array): number | null { + if ( + bytes.length < HEADER_BYTES || + bytes[0] !== TERMINAL_STREAM_KIND || + bytes[1] !== TERMINAL_STREAM_VERSION + ) { + return null + } + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(4, true) +} + +/** Overwrites the stream id of a frame `readTerminalStreamFrameId` accepted. */ +export function writeTerminalStreamFrameId(bytes: Uint8Array, streamId: number): void { + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(4, streamId, true) +} + export function encodeTerminalStreamJson(value: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)) } From 6a77d05ee0c01d60d511eb168cd52a376a661033 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:12:36 -0700 Subject: [PATCH 16/58] refactor(runtime): compose typed file search and listing ownership (#27396) Compose typed file search and listing with narrow workspace/store dependencies and preserve the existing runtime command facade. Move the mobile inventory cache to its owner, remove four inheritance classes and six unchecked-file entries, and delete unused process bookkeeping while retaining producer-owned cancellation. Preserve file access, provider capability fallbacks, host isolation, remote negotiation, budgets and cache lifetime. Independent audits verify moved method bodies, parameters, defaults and literals. Local tests and matched native Windows controls, typechecks, quality checks and CI qualification are recorded in the PR. --- .../scripts/node-server-change-scope.test.mjs | 1 + config/ts-nocheck-baseline.txt | 6 - .../filesystem-markdown-listing.test.ts | 20 ++ .../providers/filesystem-markdown-listing.ts | 14 +- .../providers/local-filesystem-provider.ts | 36 +-- src/main/runtime/orca-runtime-files.ts | 4 +- .../runtime/runtime-file-command-access.ts | 205 ++++++++++++++++ .../runtime/runtime-file-command-target.ts | 2 +- ...e-commands-active-runtime-text-searches.ts | 25 -- .../runtime-file-commands-read-mobile-file.ts | 23 +- ...ime-file-commands-resolve-terminal-path.ts | 2 +- ...ile-commands-search-local-runtime-files.ts | 43 ---- ...nds-search-remote-quick-open-file-paths.ts | 79 ------- ...time-file-commands-search-runtime-files.ts | 141 ----------- src/main/runtime/runtime-file-search.test.ts | 78 +++++++ ...-constructor.ts => runtime-file-search.ts} | 219 ++++++++++-------- .../runtime-remote-quick-open-search.ts | 56 +++++ 17 files changed, 531 insertions(+), 423 deletions(-) create mode 100644 src/main/providers/filesystem-markdown-listing.test.ts create mode 100644 src/main/runtime/runtime-file-command-access.ts delete mode 100644 src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts delete mode 100644 src/main/runtime/runtime-file-commands-search-local-runtime-files.ts delete mode 100644 src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts delete mode 100644 src/main/runtime/runtime-file-commands-search-runtime-files.ts create mode 100644 src/main/runtime/runtime-file-search.test.ts rename src/main/runtime/{runtime-file-commands-constructor.ts => runtime-file-search.ts} (57%) create mode 100644 src/main/runtime/runtime-remote-quick-open-search.ts diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 36e7031dc805..ba9ea2c6eccc 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -400,6 +400,7 @@ it('keeps every host when the modified SSH production module is an actual consum { modifiedFiles: changed } ) expect(scope.shouldRun).toBe(true) + expect(scope).toMatchObject({ graphResolved: true, matchedFiles: [registry, ssh] }) expect(nodeServerQualification(changed, scope)).toEqual({ qualification: true, runners: NODE_SERVER_RUNNERS diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index 787e9ffe89a4..454f522be4a8 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -150,19 +150,13 @@ src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts src/main/runtime/runtime-browser-commands-state.ts src/main/runtime/runtime-file-command-host.ts -src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts -src/main/runtime/runtime-file-commands-constructor.ts src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts -src/main/runtime/runtime-file-commands-read-mobile-file.ts src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts src/main/runtime/runtime-file-commands-resolve-terminal-path.ts src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts -src/main/runtime/runtime-file-commands-search-local-runtime-files.ts -src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts -src/main/runtime/runtime-file-commands-search-runtime-files.ts src/main/runtime/runtime-file-commands-terminal-artifact-access.ts src/main/runtime/runtime-file-commands-terminal-file-paths.ts src/main/runtime/runtime-file-commands-write-file-explorer-file.ts diff --git a/src/main/providers/filesystem-markdown-listing.test.ts b/src/main/providers/filesystem-markdown-listing.test.ts new file mode 100644 index 000000000000..e010ab9cc953 --- /dev/null +++ b/src/main/providers/filesystem-markdown-listing.test.ts @@ -0,0 +1,20 @@ +import { expect, it, vi } from 'vitest' +import { listFilesystemMarkdownDocuments } from './filesystem-markdown-listing' + +it('uses a native markdown capability without requiring unrelated provider methods', async () => { + const documents = [ + { filePath: '/repo/docs/a.md', relativePath: 'docs/a.md', basename: 'a.md', name: 'a' } + ] + const listMarkdownDocuments = vi.fn(async () => documents) + await expect(listFilesystemMarkdownDocuments({ listMarkdownDocuments }, '/repo')).resolves.toBe( + documents + ) + expect(listMarkdownDocuments).toHaveBeenCalledWith('/repo') +}) + +it('retains the path-list fallback for providers without native markdown listing', async () => { + const listFiles = vi.fn(async () => ['b.mdx', 'ignored.ts', '../escape.md', 'a.md']) + const documents = await listFilesystemMarkdownDocuments({ listFiles }, '/repo') + expect(documents.map((document) => document.relativePath)).toEqual(['a.md', 'b.mdx']) + expect(listFiles).toHaveBeenCalledWith('/repo') +}) diff --git a/src/main/providers/filesystem-markdown-listing.ts b/src/main/providers/filesystem-markdown-listing.ts index e2a45e2c82df..106a8813eada 100644 --- a/src/main/providers/filesystem-markdown-listing.ts +++ b/src/main/providers/filesystem-markdown-listing.ts @@ -2,11 +2,21 @@ import type { IFilesystemProvider } from './types' import { markdownDocumentsFromRelativePaths } from '../../shared/markdown-document-paths' import { FileInventoryBudget } from '../../shared/file-inventory-budget' +type MarkdownListingProvider = + | Required> + | Pick + +function hasMarkdownListing( + provider: MarkdownListingProvider +): provider is Required> { + return Boolean(provider.listMarkdownDocuments) +} + export async function listFilesystemMarkdownDocuments( - provider: IFilesystemProvider, + provider: MarkdownListingProvider, rootPath: string ) { - if (provider.listMarkdownDocuments) { + if (hasMarkdownListing(provider)) { return provider.listMarkdownDocuments(rootPath) } const paths = await provider.listFiles(rootPath) diff --git a/src/main/providers/local-filesystem-provider.ts b/src/main/providers/local-filesystem-provider.ts index 18f0e15f02ee..0e4532fa97d8 100644 --- a/src/main/providers/local-filesystem-provider.ts +++ b/src/main/providers/local-filesystem-provider.ts @@ -28,24 +28,24 @@ export type LocalFilesystemProviderOptions = { * Contract methods with a runtime-command local twin. Reads, listing and watching keep * their existing local call sites: their local limits and result shapes differ from the contract. */ -export type LocalFilesystemProvider = Pick< - IFilesystemProvider, - | 'readDir' - | 'readDocPreviewFile' - | 'writeFile' - | 'writeFileBase64' - | 'writeFileBase64Chunk' - | 'pathsExist' - | 'stat' - | 'deletePath' - | 'createFile' - | 'createDir' - | 'createDirNoClobber' - | 'renameNoClobber' - | 'copy' - | 'search' - | 'listMarkdownDocuments' -> +export type LocalFilesystemProvider = Required> & + Pick< + IFilesystemProvider, + | 'readDir' + | 'readDocPreviewFile' + | 'writeFile' + | 'writeFileBase64' + | 'writeFileBase64Chunk' + | 'pathsExist' + | 'stat' + | 'deletePath' + | 'createFile' + | 'createDir' + | 'createDirNoClobber' + | 'renameNoClobber' + | 'copy' + | 'search' + > /** This machine's file operations behind the store's path authorization; build one per call. */ export function createLocalFilesystemProvider( diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 75a59f606c5e..6d030df33b01 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1,6 +1,6 @@ -import { RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths } from './runtime-file-commands-search-remote-quick-open-file-paths' +import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber' -export class RuntimeFileCommands extends RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths {} +export class RuntimeFileCommands extends RuntimeFileCommandsWithCreateFileExplorerDirNoClobber {} export { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-commands-mobile-file-list-limit' export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-watcher-leases' diff --git a/src/main/runtime/runtime-file-command-access.ts b/src/main/runtime/runtime-file-command-access.ts new file mode 100644 index 000000000000..4bd27d12a89f --- /dev/null +++ b/src/main/runtime/runtime-file-command-access.ts @@ -0,0 +1,205 @@ +import { RuntimeFileSearch } from './runtime-file-search' +import { listFilesystemMarkdownDocuments } from '../providers/filesystem-markdown-listing' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { + validatePathExistenceBatch, + type PathExistenceResult +} from '../../shared/path-existence-batch' +import { readRuntimeFilePathExistence } from './runtime-file-path-existence' +import { + isMobileBinaryPath, + isMobileMarkdownPath, + isSafeMobileRelativePath, + type RuntimeFileCommandHost +} from './runtime-file-command-host' +import type { RuntimeFileOpenResult } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import { + isMobilePreviewableImagePath, + type TerminalFileGrant +} from './runtime-file-commands-mobile-file-list-limit' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { + requireRuntimeFileProvider, + runtimeFileRouteForTarget, + type RuntimeFileExplorerPath +} from './runtime-file-command-target' +import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import type { FileStat } from '../providers/types' + +export class RuntimeFileCommandAccess { + protected readonly terminalFileGrants = new Map() + private readonly search: RuntimeFileSearch + + constructor(protected readonly host: RuntimeFileCommandHost) { + this.search = new RuntimeFileSearch(host) + } + + listMobileFiles(...args: Parameters) { + return this.search.listMobileFiles(...args) + } + + searchMobileFilePaths(...args: Parameters) { + return this.search.searchMobileFilePaths(...args) + } + + searchQuickOpenFilePaths(...args: Parameters) { + return this.search.searchQuickOpenFilePaths(...args) + } + + searchRuntimeFiles(...args: Parameters) { + return this.search.searchRuntimeFiles(...args) + } + + listRuntimeFiles(...args: Parameters) { + return this.search.listRuntimeFiles(...args) + } + + async openMobileFile( + worktreeSelector: string, + relativePath: string, + navigation?: RuntimeNavigationTarget + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree } = target + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + const kind = isMobilePreviewableImagePath(relativePath) + ? 'image' + : isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + // Why: `kind` only describes the file; the desktop editor opens binaries (e.g. PDFs) like the File Explorer. + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: CLI/agents treat opened:true as success; stat first so missing paths and directories fail the RPC instead of opening a ghost tab. + await this.assertOpenTargetIsFile(filePath, target) + this.host.openFile( + worktree.id, + filePath, + relativePath, + runtimeOwnerForTarget(target), + navigation + ) + return { worktree: worktree.id, relativePath, kind, opened: true } + } + + protected async assertOpenTargetIsFile( + filePath: string, + target: { executionHostId: ExecutionHostId } + ): Promise { + const route = runtimeFileRouteForTarget(target) + let stats: FileStat + try { + stats = await requireRuntimeFileProvider(target, this.host, route).stat(filePath) + } catch (error) { + if ( + isENOENT(error) || + (route.kind === 'ssh' && RuntimeFileCommandAccess.isRemoteNotFoundErrorMessage(error)) + ) { + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) + } + throw error + } + if (stats.type === 'directory') { + throw new Error(`EISDIR: illegal operation on a directory, open '${filePath}'`) + } + } + + async openMobileDiff( + worktreeSelector: string, + relativePath: string, + staged: boolean, + navigation?: RuntimeNavigationTarget + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree } = target + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + const kind = isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + this.host.openDiff( + worktree.id, + filePath, + relativePath, + staged, + runtimeOwnerForTarget(target), + navigation + ) + return { worktree: worktree.id, relativePath, kind, opened: true } + } + + protected async resolveFileExplorerPath( + worktreeSelector: string, + relativePath: string + ): Promise { + const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) + return target + } + + protected async resolveFileExplorerPaths( + worktreeSelector: string, + relativePaths: readonly string[] + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + return relativePaths.map((relativePath) => ({ + worktree: target.worktree, + path: joinWorktreeRelativePath( + target.worktree.path, + normalizeRuntimeRelativePath(relativePath, target.worktree.path) + ), + executionHostId: target.executionHostId + })) + } + + async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + return listFilesystemMarkdownDocuments( + requireRuntimeFileProvider(target, this.host), + target.worktree.path + ) + } + + async pathsExistRuntimeFiles( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + validatePathExistenceBatch(relativePaths) + const targets = await this.resolveFileExplorerPaths(worktreeSelector, relativePaths) + return readRuntimeFilePathExistence(targets, this.host) + } + + async statRuntimeFile( + worktreeSelector: string, + relativePath: string + ): Promise<{ size: number; isDirectory: boolean; mtime: number; ctime?: number }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const fileStat = await requireRuntimeFileProvider(target, this.host).stat(target.path) + return { + size: fileStat.size, + isDirectory: fileStat.type === 'directory', + mtime: fileStat.mtime, + ...(fileStat.ctimeMs === undefined ? {} : { ctime: fileStat.ctimeMs }) + } + } + + // The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors). + protected static isRemoteNotFoundErrorMessage(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /\bENOENT\b|no such file|not found|does not exist/i.test(message) + } +} + +// Why: name the resolved owner; undefined would let the renderer fall back to the focused server. +function runtimeOwnerForTarget(target: { executionHostId: ExecutionHostId }): string | null { + const host = parseExecutionHostId(target.executionHostId) + return host?.kind === 'runtime' ? host.environmentId : null +} diff --git a/src/main/runtime/runtime-file-command-target.ts b/src/main/runtime/runtime-file-command-target.ts index 5d5dbb6f6675..12d43a23f585 100644 --- a/src/main/runtime/runtime-file-command-target.ts +++ b/src/main/runtime/runtime-file-command-target.ts @@ -77,7 +77,7 @@ export function requireRuntimeFileProvider( target: { executionHostId: ExecutionHostId }, host: RuntimeLocalFileHost, route: RuntimeFileRoute = runtimeFileRouteForTarget(target) -): LocalFilesystemProvider { +): LocalFilesystemProvider | IFilesystemProvider { if (route.kind === 'ssh') { return requireSshRuntimeFileProvider(route) } diff --git a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts b/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts deleted file mode 100644 index 99312a0b059a..000000000000 --- a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import type { ChildProcessHandle } from '@orca/process-host/process-spec' -import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' -import { - MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, - MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS -} from './runtime-file-commands-mobile-file-list-limit' -import { RuntimeMobileFilePathSearchCache } from './runtime-mobile-file-path-search' - -export class RuntimeFileCommandsWithActiveRuntimeTextSearches { - protected activeRuntimeTextSearches = new Map() - - protected terminalFileGrants = new Map() - - protected mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( - MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, - MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS - ) - - // The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors). - protected static isRemoteNotFoundErrorMessage(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /\bENOENT\b|no such file|not found|does not exist/i.test(message) - } -} diff --git a/src/main/runtime/runtime-file-commands-read-mobile-file.ts b/src/main/runtime/runtime-file-commands-read-mobile-file.ts index a417d5347789..8dbfb2008df8 100644 --- a/src/main/runtime/runtime-file-commands-read-mobile-file.ts +++ b/src/main/runtime/runtime-file-commands-read-mobile-file.ts @@ -1,5 +1,6 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeFileCommandsWithConstructor } from './runtime-file-commands-constructor' +import type { IFilesystemProvider } from '../providers/types' +import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' +import { RuntimeFileCommandAccess } from './runtime-file-command-access' import type { RuntimeFileReadResult } from '../../shared/runtime-types' import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host' import { joinWorktreeRelativePath } from './runtime-relative-paths' @@ -10,7 +11,7 @@ import { runtimeFileRouteForTarget } from './runtime-file-command-target' -export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWithConstructor { +export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandAccess { async readMobileFile( worktreeSelector: string, relativePath: string @@ -42,4 +43,20 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWi byteLength: truncated.byteLength } } + + protected async readRemoteMobileFile( + filePath: string, + provider: IFilesystemProvider + ): Promise { + const fileStat = await provider.stat(filePath) + // Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it. + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const result = await provider.readFile(filePath) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } } diff --git a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts index 8b6ca0c5c514..ac4aba2e3c83 100644 --- a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split class members. import { RuntimeFileCommandsWithReadMobileFile } from './runtime-file-commands-read-mobile-file' -import { RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands } from './runtime-file-commands-active-runtime-text-searches' +import { RuntimeFileCommandAccess as RuntimeFileCommands } from './runtime-file-command-access' import type { RuntimeNativeChatFileContext, RuntimeTerminalPathResolution diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts deleted file mode 100644 index 1b3080b6a1c0..000000000000 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' -import type { RuntimeFileExplorerPath } from './runtime-file-command-target' -import type { IFilesystemProvider } from '../providers/types' -import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' - -export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileCommandsWithSearchRuntimeFiles { - protected async resolveFileExplorerPath( - worktreeSelector: string, - relativePath: string - ): Promise { - const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) - return target - } - - protected async resolveFileExplorerPaths( - worktreeSelector: string, - relativePaths: readonly string[] - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - return relativePaths.map((relativePath) => ({ - worktree: target.worktree, - path: joinWorktreeRelativePath( - target.worktree.path, - normalizeRuntimeRelativePath(relativePath, target.worktree.path) - ), - executionHostId: target.executionHostId - })) - } - - // `null` provider is the caller's "this host is unreachable" answer, not "list it here". - protected async listRemoteMobileFiles( - rootPath: string, - provider: IFilesystemProvider | null, - maxResults?: number, - signal?: AbortSignal - ): Promise { - if (!provider) { - return [] - } - return provider.listFiles(rootPath, { maxResults, signal }) - } -} diff --git a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts b/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts deleted file mode 100644 index 195c054c384a..000000000000 --- a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeFileCommandsWithSearchLocalRuntimeFiles } from './runtime-file-commands-search-local-runtime-files' -import { resolveSshQuickOpenDiscoveryOptions } from '../providers/ssh-quick-open-discovery-options' -import type { IFilesystemProvider } from '../providers/types' -import { - MOBILE_FILE_READ_MAX_BYTES, - QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT -} from './runtime-file-commands-mobile-file-list-limit' -import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' - -export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends RuntimeFileCommandsWithSearchLocalRuntimeFiles { - protected async searchRemoteQuickOpenFilePaths( - rootPath: string, - // `null` is "remote and currently unreachable": quick open reports no matches rather than - // failing the keystroke, but it never falls back to searching this machine. - provider: IFilesystemProvider | null, - query: string, - limit: number, - excludePaths?: string[], - signal?: AbortSignal, - options: { - includeIgnored?: boolean - followSymlinks?: boolean - allowLegacyIncludeIgnored?: boolean - } = {} - ): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { - if (!provider) { - return { paths: [], totalCount: 0, truncated: false } - } - const discovery = await resolveSshQuickOpenDiscoveryOptions(provider, options, signal) - if (!(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))) { - // Old relays ignore searchQuery. Keep the compatibility request below the - // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. - const legacyFiles = await provider.listFiles(rootPath, { - excludePaths, - maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, - signal - }) - const ranker = new QuickOpenPathRanker(query, limit) - for (const file of legacyFiles) { - ranker.consider(file) - } - const result = ranker.result() - return { - ...result, - truncated: - legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit - } - } - const files = await provider.listFiles(rootPath, { - excludePaths, - maxResults: limit + 1, - ...discovery, - searchQuery: query, - signal - }) - return { - paths: files.slice(0, limit), - totalCount: files.length, - truncated: files.length > limit - } - } - - protected async readRemoteMobileFile( - filePath: string, - provider: IFilesystemProvider - ): Promise { - const fileStat = await provider.stat(filePath) - // Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it. - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } -} diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts deleted file mode 100644 index e5595e181152..000000000000 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ /dev/null @@ -1,141 +0,0 @@ -// @ts-nocheck -- mechanically split class members. -import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber' -import { listFilesystemMarkdownDocuments } from '../providers/filesystem-markdown-listing' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import { - requireRuntimeFileProvider, - runtimeFileRouteForTarget -} from './runtime-file-command-target' -import { randomUUID } from 'node:crypto' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' -import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' -import { listQuickOpenFiles } from '../ipc/filesystem-list-files' -import type { MarkdownDocument } from '../../shared/filesystem-entry-types' -import { - validatePathExistenceBatch, - type PathExistenceResult -} from '../../shared/path-existence-batch' -import { readRuntimeFilePathExistence } from './runtime-file-path-existence' -import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileCommandsWithCreateFileExplorerDirNoClobber { - async searchRuntimeFiles( - worktreeSelector: string, - options: Omit, - requestOptions: { signal?: AbortSignal } = {} - ): Promise { - throwIfSignalAborted(requestOptions.signal) - const target = await waitForPromiseWithSignal( - this.host.resolveRuntimeFileTarget(worktreeSelector), - requestOptions.signal - ) - throwIfSignalAborted(requestOptions.signal) - const provider = requireRuntimeFileProvider(target, { - requireStore: () => this.host.requireStore(), - // Why: local ripgrep children stay registered with this command set for cancellation. - onTextSearchSpawn: (child) => { - const searchKey = randomUUID() - this.activeRuntimeTextSearches.set(searchKey, child) - return () => { - if (this.activeRuntimeTextSearches.get(searchKey) === child) { - this.activeRuntimeTextSearches.delete(searchKey) - } - } - } - }) - return provider.search({ ...options, rootPath: target.worktree.path }, requestOptions) - } - - async listRuntimeFiles( - worktreeSelector: string, - options: { - candidatePaths?: string[] - includeIgnored?: boolean - followSymlinks?: boolean - excludePaths?: string[] - maxContentBytes?: number - maxResults?: number - signal?: AbortSignal - } = {} - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const route = runtimeFileRouteForTarget(target) - if (route.kind === 'ssh') { - // Why: quick-open listings degrade to empty for an unreachable host rather than throwing. - const provider = route.provider - if (!provider) { - return [] - } - const maxResults = - options.maxResults ?? - (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) - if ( - (options.candidatePaths !== undefined || - options.includeIgnored === false || - options.followSymlinks) && - !(await provider.supportsQuickOpenSearch?.({ - signal: options.signal, - minimumVersion: options.candidatePaths !== undefined ? 3 : 2 - })) - ) { - throw new Error( - options.candidatePaths !== undefined - ? 'Update the remote host to validate Quick Open recent files.' - : 'Update the remote host to use Quick Open listing options.' - ) - } - const files = await provider.listFiles(target.worktree.path, { - excludePaths: options.excludePaths, - ...(options.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }), - includeIgnored: options.includeIgnored, - followSymlinks: options.followSymlinks, - maxResults, - signal: options.signal - }) - return options.maxContentBytes === undefined - ? files - : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) - } - return listQuickOpenFiles( - target.worktree.path, - this.host.requireStore(), - options.excludePaths, - options.signal, - options.maxResults, - options.maxContentBytes, - undefined, - options - ) - } - - async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - return listFilesystemMarkdownDocuments( - requireRuntimeFileProvider(target, this.host), - target.worktree.path - ) - } - - async pathsExistRuntimeFiles( - worktreeSelector: string, - relativePaths: string[] - ): Promise { - validatePathExistenceBatch(relativePaths) - const targets = await this.resolveFileExplorerPaths(worktreeSelector, relativePaths) - return readRuntimeFilePathExistence(targets, this.host) - } - - async statRuntimeFile( - worktreeSelector: string, - relativePath: string - ): Promise<{ size: number; isDirectory: boolean; mtime: number; ctime?: number }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const fileStat = await requireRuntimeFileProvider(target, this.host).stat(target.path) - return { - size: fileStat.size, - isDirectory: fileStat.type === 'directory', - mtime: fileStat.mtime, - ...(fileStat.ctimeMs === undefined ? {} : { ctime: fileStat.ctimeMs }) - } - } -} diff --git a/src/main/runtime/runtime-file-search.test.ts b/src/main/runtime/runtime-file-search.test.ts new file mode 100644 index 000000000000..ef3b8e522ae9 --- /dev/null +++ b/src/main/runtime/runtime-file-search.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' +import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' +import { RuntimeFileSearch, type RuntimeFileSearchHost } from './runtime-file-search' + +function createSearch(executionHostId: 'local' | 'ssh:unavailable' | 'runtime:other' = 'local') { + const requireStore = vi.fn(() => { + throw new Error('Must not access local storage') + }) + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'folder-1', path: '/same-root' }, + executionHostId + })) + const search = new RuntimeFileSearch({ requireStore, resolveRuntimeFileTarget }) + return { search, requireStore, resolveRuntimeFileTarget } +} + +describe('RuntimeFileSearch', () => { + it('rejects an already-aborted request before resolving its workspace', async () => { + const { search, requireStore, resolveRuntimeFileTarget } = createSearch() + const controller = new AbortController() + controller.abort() + await expect( + search.searchRuntimeFiles('folder-1', { query: 'needle' }, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(resolveRuntimeFileTarget).not.toHaveBeenCalled() + expect(requireStore).not.toHaveBeenCalled() + }) + + it('settles an abort while workspace resolution remains pending', async () => { + const { requireStore } = createSearch() + const resolveRuntimeFileTarget = vi.fn( + () => + new Promise>>( + () => {} + ) + ) + const search = new RuntimeFileSearch({ requireStore, resolveRuntimeFileTarget }) + const controller = new AbortController() + const pending = search.searchRuntimeFiles( + 'folder-1', + { query: 'needle' }, + { signal: controller.signal } + ) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(resolveRuntimeFileTarget).toHaveBeenCalledOnce() + expect(requireStore).not.toHaveBeenCalled() + }) + + it('never searches this machine for a workspace owned by another runtime', async () => { + const { search, requireStore } = createSearch('runtime:other') + await expect(search.searchRuntimeFiles('folder-1', { query: 'needle' })).rejects.toBeInstanceOf( + ExecutionHostNotDispatchableError + ) + await expect(search.listRuntimeFiles('folder-1')).rejects.toBeInstanceOf( + ExecutionHostNotDispatchableError + ) + expect(requireStore).not.toHaveBeenCalled() + }) + + it('preserves distinct search and listing results for unavailable SSH hosts', async () => { + const { search, requireStore } = createSearch('ssh:unavailable') + await expect(search.searchRuntimeFiles('folder-1', { query: 'needle' })).rejects.toThrow( + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + ) + await expect(search.listRuntimeFiles('folder-1')).resolves.toEqual([]) + await expect(search.searchQuickOpenFilePaths('folder-1', 'needle', 10)).resolves.toEqual({ + worktree: 'folder-1', + rootPath: '/same-root', + files: [], + totalCount: 0, + truncated: false, + quickOpenSearchVersion: 0 + }) + expect(requireStore).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-file-commands-constructor.ts b/src/main/runtime/runtime-file-search.ts similarity index 57% rename from src/main/runtime/runtime-file-commands-constructor.ts rename to src/main/runtime/runtime-file-search.ts index 7f3603a88b2d..15dd288b1faa 100644 --- a/src/main/runtime/runtime-file-commands-constructor.ts +++ b/src/main/runtime/runtime-file-search.ts @@ -1,42 +1,52 @@ -// @ts-nocheck -- mechanically split class members. import { QUICK_OPEN_SEARCH_VERSION, isQuickOpenQueryTooLarge } from '../../shared/quick-open-path-search' -import { - RuntimeFileCommandsWithActiveRuntimeTextSearches, - RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands -} from './runtime-file-commands-active-runtime-text-searches' -import type { RuntimeFileCommandHost } from './runtime-file-command-host' -import { - isMobileBinaryPath, - isMobileMarkdownPath, - isSafeMobileRelativePath -} from './runtime-file-command-host' -import { basenameFromRelativePath } from './runtime-file-paths' -import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types' -import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeFileListResult } from '../../shared/runtime-types' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' +import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' +import type { IFilesystemProvider } from '../providers/types' +import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-command-host' +import { + requireRuntimeFileProvider, + runtimeFileRouteForTarget, + type RuntimeLocalFileHost, + type ResolvedRuntimeFileTarget +} from './runtime-file-command-target' import { MOBILE_FILE_LIST_LIMIT, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT, - isMobilePreviewableImagePath + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS } from './runtime-file-commands-mobile-file-list-limit' -import { rankRuntimeMobileFilePaths } from './runtime-mobile-file-path-search' -import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' -import { joinWorktreeRelativePath } from './runtime-relative-paths' -import { isENOENT } from '../ipc/filesystem-path-containment' +import { basenameFromRelativePath } from './runtime-file-paths' import { - requireRuntimeFileProvider, - runtimeFileRouteForTarget -} from './runtime-file-command-target' -import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import type { FileStat } from '../providers/types' + RuntimeMobileFilePathSearchCache, + rankRuntimeMobileFilePaths +} from './runtime-mobile-file-path-search' +import { searchRemoteQuickOpenFilePaths } from './runtime-remote-quick-open-search' -export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithActiveRuntimeTextSearches { - constructor(private readonly host: RuntimeFileCommandHost) { - super() - } +type RuntimeFileSearchTarget = { + worktree: Pick + executionHostId: ResolvedRuntimeFileTarget['executionHostId'] +} + +export type RuntimeFileSearchHost = { + requireStore: RuntimeLocalFileHost['requireStore'] + resolveRuntimeFileTarget(selector: string): Promise +} + +export class RuntimeFileSearch { + private readonly mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS + ) + + constructor(private readonly host: RuntimeFileSearchHost) {} async listMobileFiles( worktreeSelector: string, @@ -159,7 +169,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA !query.trim() || isQuickOpenQueryTooLarge(query) ? { paths: [], totalCount: 0, truncated: false } : route.kind === 'ssh' - ? await this.searchRemoteQuickOpenFilePaths( + ? await searchRemoteQuickOpenFilePaths( worktree.path, route.provider, query, @@ -189,90 +199,95 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA } } - async openMobileFile( + async searchRuntimeFiles( worktreeSelector: string, - relativePath: string, - navigation?: RuntimeNavigationTarget - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree } = target - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - const kind = isMobilePreviewableImagePath(relativePath) - ? 'image' - : isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - // Why: `kind` only describes the file; the desktop editor opens binaries (e.g. PDFs) like the File Explorer. - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - // Why: CLI/agents treat opened:true as success; stat first so missing paths and directories fail the RPC instead of opening a ghost tab. - await this.assertOpenTargetIsFile(filePath, target) - this.host.openFile( - worktree.id, - filePath, - relativePath, - runtimeOwnerForTarget(target), - navigation + options: Omit, + requestOptions: { signal?: AbortSignal } = {} + ): Promise { + throwIfSignalAborted(requestOptions.signal) + const target = await waitForPromiseWithSignal( + this.host.resolveRuntimeFileTarget(worktreeSelector), + requestOptions.signal ) - return { worktree: worktree.id, relativePath, kind, opened: true } + throwIfSignalAborted(requestOptions.signal) + const provider = requireRuntimeFileProvider(target, { + requireStore: () => this.host.requireStore() + }) + return provider.search({ ...options, rootPath: target.worktree.path }, requestOptions) } - protected async assertOpenTargetIsFile( - filePath: string, - target: { executionHostId: ExecutionHostId } - ): Promise { + async listRuntimeFiles( + worktreeSelector: string, + options: { + candidatePaths?: string[] + includeIgnored?: boolean + followSymlinks?: boolean + excludePaths?: string[] + maxContentBytes?: number + maxResults?: number + signal?: AbortSignal + } = {} + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const route = runtimeFileRouteForTarget(target) - let stats: FileStat - try { - stats = await requireRuntimeFileProvider(target, this.host, route).stat(filePath) - } catch (error) { + if (route.kind === 'ssh') { + // Why: quick-open listings degrade to empty for an unreachable host rather than throwing. + const provider = route.provider + if (!provider) { + return [] + } + const maxResults = + options.maxResults ?? + (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) if ( - isENOENT(error) || - (route.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + (options.candidatePaths !== undefined || + options.includeIgnored === false || + options.followSymlinks) && + !(await provider.supportsQuickOpenSearch?.({ + signal: options.signal, + minimumVersion: options.candidatePaths !== undefined ? 3 : 2 + })) ) { - throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) + throw new Error( + options.candidatePaths !== undefined + ? 'Update the remote host to validate Quick Open recent files.' + : 'Update the remote host to use Quick Open listing options.' + ) } - throw error - } - if (stats.type === 'directory') { - throw new Error(`EISDIR: illegal operation on a directory, open '${filePath}'`) + const files = await provider.listFiles(target.worktree.path, { + excludePaths: options.excludePaths, + ...(options.candidatePaths === undefined ? {} : { candidatePaths: options.candidatePaths }), + includeIgnored: options.includeIgnored, + followSymlinks: options.followSymlinks, + maxResults, + signal: options.signal + }) + return options.maxContentBytes === undefined + ? files + : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) } + return listQuickOpenFiles( + target.worktree.path, + this.host.requireStore(), + options.excludePaths, + options.signal, + options.maxResults, + options.maxContentBytes, + undefined, + options + ) } - async openMobileDiff( - worktreeSelector: string, - relativePath: string, - staged: boolean, - navigation?: RuntimeNavigationTarget - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree } = target - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') + // `null` provider is the caller's "this host is unreachable" answer, not "list it here". + private async listRemoteMobileFiles( + rootPath: string, + provider: IFilesystemProvider | null, + maxResults?: number, + signal?: AbortSignal + ): Promise { + if (!provider) { + return [] } - const kind = isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - this.host.openDiff( - worktree.id, - filePath, - relativePath, - staged, - runtimeOwnerForTarget(target), - navigation - ) - return { worktree: worktree.id, relativePath, kind, opened: true } + return provider.listFiles(rootPath, { maxResults, signal }) } } - -// Why: name the resolved owner; undefined would let the renderer fall back to the focused server. -function runtimeOwnerForTarget(target: { executionHostId: ExecutionHostId }): string | null { - const host = parseExecutionHostId(target.executionHostId) - return host?.kind === 'runtime' ? host.environmentId : null -} diff --git a/src/main/runtime/runtime-remote-quick-open-search.ts b/src/main/runtime/runtime-remote-quick-open-search.ts new file mode 100644 index 000000000000..58c121110ff9 --- /dev/null +++ b/src/main/runtime/runtime-remote-quick-open-search.ts @@ -0,0 +1,56 @@ +import { resolveSshQuickOpenDiscoveryOptions } from '../providers/ssh-quick-open-discovery-options' +import type { IFilesystemProvider } from '../providers/types' +import { QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT } from './runtime-file-commands-mobile-file-list-limit' +import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' + +export async function searchRemoteQuickOpenFilePaths( + rootPath: string, + // `null` is "remote and currently unreachable": quick open reports no matches rather than + // failing the keystroke, but it never falls back to searching this machine. + provider: IFilesystemProvider | null, + query: string, + limit: number, + excludePaths?: string[], + signal?: AbortSignal, + options: { + includeIgnored?: boolean + followSymlinks?: boolean + allowLegacyIncludeIgnored?: boolean + } = {} +): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { + if (!provider) { + return { paths: [], totalCount: 0, truncated: false } + } + const discovery = await resolveSshQuickOpenDiscoveryOptions(provider, options, signal) + if (!(await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 1 }))) { + // Old relays ignore searchQuery. Keep the compatibility request below the + // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. + const legacyFiles = await provider.listFiles(rootPath, { + excludePaths, + maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, + signal + }) + const ranker = new QuickOpenPathRanker(query, limit) + for (const file of legacyFiles) { + ranker.consider(file) + } + const result = ranker.result() + return { + ...result, + truncated: + legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit + } + } + const files = await provider.listFiles(rootPath, { + excludePaths, + maxResults: limit + 1, + ...discovery, + searchQuery: query, + signal + }) + return { + paths: files.slice(0, limit), + totalCount: files.length, + truncated: files.length > limit + } +} From 68dc48a21cdc110efd1f19bcc7080f6844fe2a9f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 00:19:25 -0700 Subject: [PATCH 17/58] fix(packaging): unpack the orcad serve-selection entry so orca serve can run it (#27307) (#27404) Co-authored-by: m4air --- config/electron-builder.config.cjs | 2 ++ .../scripts/electron-builder-config.test.mjs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 30b61e6b2e77..ef545043146a 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -301,6 +301,7 @@ module.exports = { // disappears from Agent Session History in packaged builds only. Worker // entries reached solely from the Electron main process stay packed, since // asar redirects their app.asar paths. + // Why: `orca serve` runs the orcad selection entry under ELECTRON_RUN_AS_NODE too (#27307). asarUnpack: [ 'out/package.json', 'out/cli/**', @@ -327,6 +328,7 @@ module.exports = { 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', + 'out/main/orcad/orcad-local-serve-selection-entry.js', 'out/main/chunks/**', 'resources/**', 'node_modules/ws/**', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3daa56d1aa69..2c0af72da985 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -357,6 +357,28 @@ describe('electron-builder config', () => { expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)) }) + // Why: the CLI runs this entry under ELECTRON_RUN_AS_NODE, which cannot read + // app.asar, so a packed copy sends every `orca serve` to Electron (#27307). + it('unpacks the orcad serve-selection entry the CLI runs', async () => { + const sharedSource = await readFile( + join(REPO_ROOT, 'src', 'shared', 'orcad-local-serve-selection.ts'), + 'utf8' + ) + const entryName = sharedSource.match(/ORCAD_LOCAL_SERVE_SELECTION_ENTRY = '([^']+)'/)?.[1] + expect(entryName).toBeDefined() + + const matcher = new FileMatcher( + '/app', + '/dest', + (value) => value, + electronBuilderConfig.asarUnpack + ) + const isUnpacked = matcher.createFilter() + expect( + isUnpacked(join('/app', 'out', 'main', `${entryName}.js`), { isDirectory: () => false }) + ).toBe(true) + }) + // Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is // invisible to it and a packed worker entry fails closed — dropping every // OpenCode session in packaged builds while dev stays green. Three legs must From cc5c85fb7a43ed52c6cd29157210020cd769d84c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 00:19:28 -0700 Subject: [PATCH 18/58] ci: ship the orcad template in hourly and daily builds (#27407) Co-authored-by: m4air --- .github/workflows/daily-mac-build.yml | 55 ++++++++++++++-- .github/workflows/dev-channel-win-build.yml | 2 +- .github/workflows/hourly-mac-build.yml | 22 +++++++ ...v-channel-orcad-template-workflow.test.mjs | 65 +++++++++++++++++++ .../hourly-preflight-workflow.test.mjs | 2 +- 5 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 config/scripts/dev-channel-orcad-template-workflow.test.mjs diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 788ea8734812..9f013a8c7248 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -70,20 +70,50 @@ env: DAILY_RETAIN_COUNT: 30 jobs: + # Why: the app and the orcad template it ships must come from one commit, so resolve main once. + resolve-main: + if: github.repository == 'stablyai/orca' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + head_sha: ${{ steps.resolve.outputs.head_sha }} + steps: + - name: Resolve main + id: resolve + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + head_sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" + [[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Could not resolve main"; exit 1; } + echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" + # Why its own job: this package ships relays for Windows SSH hosts, and only a # Windows runner compiles the addon that launches one outside sshd's job. - # Why main and not the mac job's head_sha: that is resolved inside the mac job. - # A commit or two of drift is harmless; build-relay rejects a stale or wrong-arch addon. relay-windows-process-tree: - if: github.repository == 'stablyai/orca' + needs: resolve-main permissions: contents: read uses: ./.github/workflows/relay-windows-process-tree.yml with: - ref: main + ref: ${{ needs.resolve-main.outputs.head_sha }} + + # As release-cut does: every SSH host runs orcad, deployed from the template this build ships. + orcad-template: + needs: resolve-main + permissions: + contents: read + uses: ./.github/workflows/node-server-tests.yml + with: + ref: ${{ needs.resolve-main.outputs.head_sha }} + build_template: true build-daily-mac: - needs: relay-windows-process-tree + needs: + - resolve-main + - relay-windows-process-tree + - orcad-template if: github.repository == 'stablyai/orca' outputs: tag: ${{ steps.release.outputs.tag }} @@ -102,7 +132,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: main + ref: ${{ needs.resolve-main.outputs.head_sha }} # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write @@ -249,7 +279,7 @@ jobs: node config/scripts/daily-build-version.mjs \ >"$RUNNER_TEMP/daily-identity.txt" grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt" - # Why check rather than trust: the checkout above pins `ref: main`, but a + # Why check rather than trust: the checkout above pins the resolved main commit, but a # workflow_dispatch runs this file from whatever branch was dispatched. A # branch that edits this step while main still has the old script yields # an empty name and an untitled release — silent, and only visible once @@ -282,6 +312,14 @@ jobs: # sshd's job for a standard user on a Windows SSH host. ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 + # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. + - name: Download the orcad deployment template + if: steps.freshness.outputs.should_build == 'true' + uses: actions/download-artifact@v8 + with: + name: orcad-template + path: out/orcad-template + # Why a second mint: everything from here on writes to the daily repo, and # the notary round trip inside the publish step can be tens of minutes. The # token minted at the top has already spent install + build of its one hour @@ -366,6 +404,8 @@ jobs: GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }} ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }} + # beforePack and afterPack fail the package when the template is absent. + ORCA_REQUIRE_ORCAD_TEMPLATE: '1' CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} # Why all three: electron-builder's notarize step authenticates to the @@ -577,3 +617,4 @@ jobs: tag: ${{ needs.build-daily-mac.outputs.tag }} ref: ${{ needs.build-daily-mac.outputs.head_sha }} version: ${{ needs.build-daily-mac.outputs.version }} + orcad_template: true diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 04e3b84ff7fa..4445c05ecd3c 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -59,7 +59,7 @@ on: required: true type: string orcad_template: - description: Ship the orcad-template artifact the calling run built (adhoc does; hourly and daily do not yet) + description: Ship the orcad-template artifact the calling run built required: false type: boolean default: false diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 30c06c5eaa1f..aabfc6f1cfcc 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -127,10 +127,22 @@ jobs: with: ref: ${{ needs.preflight.outputs.head_sha }} + # As release-cut does: every SSH host runs orcad, deployed from the template this build ships. + orcad-template: + needs: preflight + if: needs.preflight.outputs.should_build == 'true' + permissions: + contents: read + uses: ./.github/workflows/node-server-tests.yml + with: + ref: ${{ needs.preflight.outputs.head_sha }} + build_template: true + build-hourly-mac: needs: - preflight - relay-windows-process-tree + - orcad-template if: needs.preflight.outputs.should_build == 'true' outputs: tag: ${{ steps.release.outputs.tag }} @@ -287,6 +299,13 @@ jobs: # sshd's job for a standard user on a Windows SSH host. ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 + # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. + - name: Download the orcad deployment template + uses: actions/download-artifact@v8 + with: + name: orcad-template + path: out/orcad-template + # Why a second mint: everything from here on writes to the hourly repo, and # the notary round trip inside the publish step can be tens of minutes. The # token minted at the top has already spent install + build of its one hour @@ -360,6 +379,8 @@ jobs: GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }} ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }} + # beforePack and afterPack fail the package when the template is absent. + ORCA_REQUIRE_ORCAD_TEMPLATE: '1' CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} # Why all three: electron-builder's notarize step authenticates to the @@ -515,3 +536,4 @@ jobs: tag: ${{ needs.build-hourly-mac.outputs.tag }} ref: ${{ needs.build-hourly-mac.outputs.head_sha }} version: ${{ needs.build-hourly-mac.outputs.version }} + orcad_template: true diff --git a/config/scripts/dev-channel-orcad-template-workflow.test.mjs b/config/scripts/dev-channel-orcad-template-workflow.test.mjs new file mode 100644 index 000000000000..007a0a309d9a --- /dev/null +++ b/config/scripts/dev-channel-orcad-template-workflow.test.mjs @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +// Why: every SSH host runs orcad, deployed from the template inside the desktop build, so a +// dev-channel build without it cannot connect to any SSH host. +function readWorkflow(name) { + return parse(readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), 'utf8')) +} + +const channels = [ + { + name: 'hourly', + workflow: readWorkflow('hourly-mac-build.yml'), + macJob: 'build-hourly-mac', + winJob: 'build-hourly-win', + pinnedSha: '${{ needs.preflight.outputs.head_sha }}' + }, + { + name: 'daily', + workflow: readWorkflow('daily-mac-build.yml'), + macJob: 'build-daily-mac', + winJob: 'build-daily-win', + pinnedSha: '${{ needs.resolve-main.outputs.head_sha }}' + } +] + +describe.each(channels)('$name build ships the orcad template', (channel) => { + const { jobs } = channel.workflow + const mac = jobs[channel.macJob] + const stepIndex = (predicate) => mac.steps.findIndex(predicate) + + it('builds the template from the commit the app is built from', () => { + const template = jobs['orcad-template'] + expect(template.uses).toBe('./.github/workflows/node-server-tests.yml') + expect(template.with).toEqual({ ref: channel.pinnedSha, build_template: true }) + expect(template.permissions).toEqual({ contents: 'read' }) + expect(template.secrets).toBeUndefined() + expect(mac.steps.find((step) => step.name === 'Checkout').with.ref).toBe(channel.pinnedSha) + expect(jobs['relay-windows-process-tree'].with.ref).toBe(channel.pinnedSha) + }) + + it('packages the macOS build only with the template present', () => { + expect(mac.needs).toContain('orcad-template') + // Implicit success(): a failed template job must skip the signed build. + expect(mac.if ?? '').not.toMatch(/always\(\)|!cancelled\(\)/) + const build = stepIndex((step) => step.name === 'Build app') + const download = stepIndex((step) => step.name === 'Download the orcad deployment template') + const publish = stepIndex((step) => step.with?.command?.includes('electron-builder')) + expect(build).toBeGreaterThanOrEqual(0) + expect(download).toBeGreaterThan(build) + expect(publish).toBeGreaterThan(download) + expect(mac.steps[download].uses).toMatch(/^actions\/download-artifact@/) + expect(mac.steps[download].with).toEqual({ + name: 'orcad-template', + path: 'out/orcad-template' + }) + expect(mac.steps[publish].env.ORCA_REQUIRE_ORCAD_TEMPLATE).toBe('1') + }) + + it('ships the same template in the Windows build', () => { + expect(jobs[channel.winJob].uses).toBe('./.github/workflows/dev-channel-win-build.yml') + expect(jobs[channel.winJob].with.orcad_template).toBe(true) + }) +}) diff --git a/config/scripts/hourly-preflight-workflow.test.mjs b/config/scripts/hourly-preflight-workflow.test.mjs index 9145383559eb..f8e5fcdba8bf 100644 --- a/config/scripts/hourly-preflight-workflow.test.mjs +++ b/config/scripts/hourly-preflight-workflow.test.mjs @@ -62,7 +62,7 @@ describe('hourly build preflight', () => { expect( preflight.steps.find((step) => step.id === 'app_token').with['permission-contents'] ).toBe('read') - expect(build.needs).toEqual(['preflight', 'relay-windows-process-tree']) + expect(build.needs).toEqual(['preflight', 'relay-windows-process-tree', 'orcad-template']) expect(build.if).toBe("needs.preflight.outputs.should_build == 'true'") expect(build.steps.find((step) => step.name === 'Checkout').with.ref).toBe( build.outputs.head_sha From d56c0fbbb30c1c7705ddaf95e9e2e0e1f01b1834 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:30:06 -0700 Subject: [PATCH 19/58] fix(runtime): publish project setup changes and retire stale worktree scans (#26941) Runtime setup create/update/delete changed the store without telling open clients, and a path/kind change or deletion left cached worktree scans answering for the old registration. Publish reposChanged after each successful mutation and run the shared worktree-change invalidators on path/kind changes and deletes. Fixes #26562 Co-authored-by: m4air --- .../runtime-project-host-setup-controller.ts | 18 ++++ ...ime-project-host-setup-publication.test.ts | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/main/runtime/runtime-project-host-setup-publication.test.ts diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index 6dff6bae9848..fcd7ba286fa1 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -22,6 +22,7 @@ import { import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta' @@ -95,6 +96,7 @@ export class RuntimeProjectHostSetupController { if (!result) { throw new Error(`Project not found: ${args.projectId}`) } + this.deps.notifyReposChanged() return result } @@ -158,6 +160,11 @@ export class RuntimeProjectHostSetupController { void prepareLocalWorktreeRootForRepo(store, result.repo) invalidateAuthorizedRootsCache() } + if (result.repo && ('path' in args.updates || 'kind' in args.updates)) { + this.retireRepoCatalog(result.repo.id) + } + this.deps.invalidateResolvedWorktrees() + this.deps.notifyReposChanged() return result } @@ -173,9 +180,20 @@ export class RuntimeProjectHostSetupController { if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) } + if (result.repo) { + this.retireRepoCatalog(result.repo.id) + } + this.deps.invalidateResolvedWorktrees() + this.deps.notifyReposChanged() return result } + // Why the shared invalidators: desktop and runtime scan caches key on repo id, not path or kind. + private retireRepoCatalog(repoId: string): void { + runWorktreeChangeInvalidators(repoId) + invalidateAuthorizedRootsCache() + } + // Why only for an explicit `force: false`: older clients omit it and must keep their delete. private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void { const setup = this.listSetups().find((entry) => entry.id === setupId) diff --git a/src/main/runtime/runtime-project-host-setup-publication.test.ts b/src/main/runtime/runtime-project-host-setup-publication.test.ts new file mode 100644 index 000000000000..ad39e2a51d06 --- /dev/null +++ b/src/main/runtime/runtime-project-host-setup-publication.test.ts @@ -0,0 +1,88 @@ +// Runtime RPC setup edits used to change the store silently: open clients kept the old catalog +// and cached worktree scans kept answering for the old path or kind (#26562). +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' +import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invalidators' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import type { RuntimeStore } from './runtime-store-contract' + +const repo: Repo = { + id: 'repo-1', + path: '/srv/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} +const projection = projectHostSetupProjectionFromRepos([repo]) +const project = projection.projects[0] +const setup = projection.setups[0] + +let unregister: (() => void) | undefined +afterEach(() => unregister?.()) + +function makeController(): { + controller: RuntimeProjectHostSetupController + notifyReposChanged: ReturnType + invalidateResolvedWorktrees: ReturnType + retired: string[] +} { + const store = { + createProjectHostSetup: () => ({ project, setup }), + updateProjectHostSetup: () => ({ project, setup, repo }), + deleteProjectHostSetup: () => ({ project, setup, repo }), + getProjectHostSetups: () => [setup] + } + const retired: string[] = [] + unregister = registerWorktreeChangeInvalidator((repoId) => retired.push(repoId)) + const notifyReposChanged = vi.fn() + const invalidateResolvedWorktrees = vi.fn() + const controller = new RuntimeProjectHostSetupController({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only calls the setup methods stubbed above. + getStore: () => store as unknown as RuntimeStore, + listRepos: () => [repo], + addRepo: vi.fn(), + addRemoteRepo: vi.fn(), + cloneRepo: vi.fn(), + invalidateResolvedWorktrees, + invalidateWorktreeScan: vi.fn(), + notifyReposChanged, + countLiveTerminalsForRepo: () => 0 + }) + return { controller, notifyReposChanged, invalidateResolvedWorktrees, retired } +} + +describe('RuntimeProjectHostSetupController publication', () => { + it('publishes a created setup to open clients', () => { + const { controller, notifyReposChanged } = makeController() + controller.createSetup({ projectId: project.id, hostId: 'local', path: '/srv/app' }) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + }) + + it('publishes a rename without retiring cached worktree scans', () => { + const { controller, notifyReposChanged, invalidateResolvedWorktrees, retired } = + makeController() + controller.updateSetup({ setupId: setup.id, updates: { displayName: 'renamed' } }) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + expect(invalidateResolvedWorktrees).toHaveBeenCalled() + expect(retired).toEqual([]) + }) + + it.each([{ kind: 'folder' as const }, { path: '/srv/moved' }])( + 'retires cached worktree scans when a setup changes %o', + (updates) => { + const { controller, notifyReposChanged, retired } = makeController() + controller.updateSetup({ setupId: setup.id, updates }) + expect(retired).toEqual(['repo-1']) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + } + ) + + it('retires cached worktree scans and publishes a deleted setup', () => { + const { controller, notifyReposChanged, retired } = makeController() + controller.deleteSetup({ setupId: setup.id }) + expect(retired).toEqual(['repo-1']) + expect(notifyReposChanged).toHaveBeenCalledTimes(1) + }) +}) From e3be586ef097c9bb878bac921bf4e2abef051484 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:30:22 -0700 Subject: [PATCH 20/58] fix(ssh): a fast remote directory listing no longer reports a false failure (#26940) ssh2 can deliver the exec ack and the exit status in one chunk, so 'exit' fires before the browse handler subscribes. Fall back to the status that 'close' repeats when no exit was observed. Co-authored-by: m4air Co-authored-by: suharvest --- src/main/ipc/ssh-browse.test.ts | 55 +++++++++++++++++++++ src/main/ssh/ssh-remote-directory-browse.ts | 8 ++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 2b6b1529ed73..5c3775bb1689 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -80,6 +80,61 @@ describe('registerSshBrowseHandler', () => { expect(channel.stderr.listenerCount('error')).toBe(0) }) + it('reads the status from close when exit fired before the channel was returned', async () => { + const channel = createMockChannel() + // ssh2 can parse exit-status in the same chunk as the exec ack, before listeners attach. + const exec = vi + .fn() + .mockImplementationOnce(async () => { + channel.emit('exit', 0) + return channel + }) + .mockRejectedValue(new Error('unexpected PowerShell fallback')) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + channel.emit('data', Buffer.from('/home/user\nsrc/\n')) + channel.emit('close', 0) + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: '/home/user', + pathFlavor: 'posix', + entries: [{ name: 'src', isDirectory: true }] + }) + expect(exec).toHaveBeenCalledTimes(1) + }) + + it('keeps an observed exit status over the close status', async () => { + const channel = createMockChannel() + const fallback = createMockChannel() + const exec = vi + .fn() + .mockResolvedValueOnce(channel) + .mockImplementationOnce(async () => { + fallback.emit('exit', 127) + return fallback + }) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + const rejected = expect(resultPromise).rejects.toThrow('Remote listing failed (exit 7)') + await Promise.resolve() + channel.emit('data', Buffer.from('/home/user\n')) + channel.emit('exit', 7) + channel.emit('close', 0) + await vi.waitFor(() => expect(fallback.listenerCount('close')).toBe(1)) + fallback.emit('close', 127) + + await rejected + }) + it('escapes remote browse paths and reads the listing past rc-file stdout', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) diff --git a/src/main/ssh/ssh-remote-directory-browse.ts b/src/main/ssh/ssh-remote-directory-browse.ts index b1fd912abbbd..08e16ffe1a46 100644 --- a/src/main/ssh/ssh-remote-directory-browse.ts +++ b/src/main/ssh/ssh-remote-directory-browse.ts @@ -118,6 +118,7 @@ async function runBrowseCommand( let stdout = '' let stderr = '' let exitCode: number | null = null + let exitObserved = false let settled = false let timeout: ReturnType | null = null @@ -175,12 +176,17 @@ async function runBrowseCommand( } // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. const onExit = (code: number | null): void => { + exitObserved = true exitCode = code } const onError = (error: Error): void => { rejectOnce(error) } - const onClose = (): void => { + const onClose = (code?: number | null): void => { + // Why: ssh2 can emit `exit` before `exec` resolves; `close` repeats the status. + if (!exitObserved && typeof code === 'number') { + exitCode = code + } stdout = fence?.readStdout(stdout) ?? stdout // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. if (exitCode !== 0) { From e9928c3542d63c407c748563dacc017a7d6f43e4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:43:52 -0400 Subject: [PATCH 21/58] feat(mobile): let the desktop list its servers and their workspaces for the phone (#26442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): show SSH host health on the workspace row host badge Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name an unhealthy SSH host even in a single-host list The desktop card shows SSH status on any host count, so the phone now reads target summaries whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one target-to-host keying, and a hook-level test pins the reply-to-row wiring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear stale SSH host badges and announce them without the dot A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps, so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox, Disconnected") instead of reading the middle dot. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(runtime): let a paired desktop register its phones as delegated mobile devices A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(runtime): move the mobile device revoke primitive below dispatch Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in a layer under it as a pure move; this drops the abstract method seam. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): keep a runtime grant until its delegated phones are revoked A failed child cleanup now leaves the parent in place so a retry can still find the children, and revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage, shares credential minting, and drops the timing-dependent census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): fail a delegated sync whose dropped phone cannot be revoked Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors before minting anything, so the desktop retries instead of believing the server matches its list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): relay a paired phone's server-workspace requests through the desktop A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop now forwards a request that names `executionHost: runtime:` to that server over one socket per (phone, server), signed in as the phone's delegated device; replies pass through untouched except stream ids. Hidden until the phone client ships: the relay capability is not advertised yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the single-host health fixtures inline so the test tsc passes Fixtures hoisted into consts widened their health maps to string, which the now-required hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the desktop relay contract in its entry module main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a later request syncs it again. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the relay's configured-server adapter The production hosts adapter was only typechecked; this pins that it resolves configured servers by exact id only (never a name or a removed server), fences by pairing revision and runtime identity, and stops reporting retirements once unsubscribed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the repo-metadata goldens for SSH host health The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): tighten the desktop relay after elegance review Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request whose link ended while opening, passes failure codes through, relays only this desktop's own phones, and drops the unused host-state reader until S2 needs one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound relay frames and stop sticky relay verdicts Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on each new link so an in-place upgrade relays, answers a phone the server just declined without syncing again, and ends a link whose mirrored capability update cannot be queued. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run targeted desktop-owned calls on the desktop The phone tags every call on a workspace screen with its execution host, so a paired-desktop method carrying `runtime:` now runs locally instead of failing 'forbidden'. The Mac stays the only reader of the routing table. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the desktop's configured servers and their last workspace rows for the phone The phone needs the same host list the desktop shows, including servers that are offline or too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): list server rows through one desktop fetch, with no side effect on listing The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the desktop's own fetch is both the live source and the cache, and stamping stays in main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows when a server hangs, drops a server's rows when it is removed or re-paired, and checks the server's identity after the fetch so a reply from before a re-pair is never served. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete A server Orca manages on an SSH host now lists once under that host's name, or not at all while the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is deleting are left out at reply time, as a phone asking the server itself would see. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied the phone's list; rows are now pruned only once a server is no longer configured. Fetches are shared per server identity, so a poll after a re-pair asks the new server right away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): run the remote-runtime WebSocket opener test on Node Under the bun vitest project, `ws` resolves to Bun's built-in client, which ignores maxPayload, so the frame-cap test hung. The opener only ships on Node/Electron, so run its test in the Node runtime project like its siblings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): derive the phone allowlist from the routing table Adding a phone method now takes one edit (its route). A server-targeted call on a legacy non-E2EE socket is refused instead of running locally, and only execution-host methods enter the relay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): drop unread freshness fields from server workspace lists The phone reads only the rows, so stale, fetchedAt, totalCount and truncated were dead wire weight. The desktop still serves a server's last rows while the server is unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): classify the mobileRelay RPC namespace as host-owned main now requires every runtime RPC namespace in the host-operation ownership table; the relay-hosts methods are served by the desktop the phone is paired to. --- ...ime-environment-mobile-relay-hosts.test.ts | 115 ++++- .../runtime-environment-mobile-relay-hosts.ts | 69 ++- .../mobile-desktop-relay-hosts.ts | 34 +- .../mobile-desktop-relay.integration.test.ts | 17 +- .../mobile-desktop-relay.test.ts | 6 + ...ile-relay-host-catalog.integration.test.ts | 203 +++++++++ .../mobile-relay-host-catalog.test.ts | 398 ++++++++++++++++++ .../mobile-relay-host-catalog.ts | 212 ++++++++++ src/main/runtime/rpc/core.ts | 9 + .../runtime/rpc/dispatcher-stream-options.ts | 2 + src/main/runtime/rpc/methods/index.ts | 2 + .../runtime/rpc/methods/mobile-relay-hosts.ts | 23 + ...ntime-rpc-mobile-paired-desktop-methods.ts | 3 + .../runtime-rpc/runtime-rpc-pairing.ts | 8 + .../runtime/runtime-rpc/runtime-rpc-state.ts | 4 +- .../runtime-rpc-websocket-dispatch.ts | 1 + .../store/slices/runtime-status-snapshot.ts | 15 +- src/shared/host-operation-ownership.ts | 1 + src/shared/mobile-relay-hosts-contract.ts | 31 ++ .../rpc-params-catalog.generated.ts | 3 + src/shared/runtime-host-status.ts | 16 + 21 files changed, 1150 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts create mode 100644 src/main/runtime/rpc/methods/mobile-relay-hosts.ts create mode 100644 src/shared/mobile-relay-hosts-contract.ts diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts index 99ac27a765d1..ca0c08863424 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { - const environments: { id: string; name: string }[] = [] - return { environments, resolveManaged: vi.fn(), call: vi.fn() } + const environments: Record[] = [] + const snapshots: Record[] = [] + const sshTargets: { id: string; label: string }[] = [] + const sshStates = new Map>() + return { environments, snapshots, sshTargets, sshStates, resolveManaged: vi.fn(), call: vi.fn() } }) vi.mock('electron', () => ({ app: { getPath: () => '/user-data' } })) @@ -17,6 +20,13 @@ vi.mock('../../shared/runtime-environments', () => ({ vi.mock('./runtime-environment-managed-tunnel', () => ({ resolveManagedRuntimeEnvironment: mocks.resolveManaged })) +vi.mock('../ssh/ssh-target-registry', () => ({ + listRegisteredSshTargets: () => mocks.sshTargets, + getRegisteredSshState: (targetId: string) => mocks.sshStates.get(targetId) +})) +vi.mock('./runtime-environment-request-connections', () => ({ + getRuntimeEnvironmentStatusSnapshots: () => mocks.snapshots +})) vi.mock('./runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: mocks.call })) @@ -37,6 +47,76 @@ describe('runtime environment mobile relay hosts', () => { runtimeId: 'runtime-a' }) mocks.call.mockReset() + mocks.snapshots = [] + }) + + it("lists configured servers with the desktop's own status, ignoring another pairing's snapshot", () => { + mocks.environments = [ + { id: 'env-1', name: 'VM', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-a' }, + { id: 'env-2', name: 'Box', createdAt: 2, runtimeId: null, source: 'ephemeral-vm' } + ] + const status = { runtimeId: 'runtime-a', capabilities: [] } + mocks.snapshots = [ + { + environmentId: 'env-1', + pairingRevision: 7, + checkedAt: 5, + status, + verification: 'verified', + transport: 'ready' + }, + { + environmentId: 'env-2', + pairingRevision: 1, + checkedAt: 5, + status, + verification: 'verified', + transport: 'ready' + }, + { + environmentId: 'removed', + pairingRevision: 1, + checkedAt: 5, + status, + verification: 'verified', + transport: 'ready' + } + ] + mocks.sshTargets = [ + { id: 'devbox', label: 'Dev Box' }, + { id: 'never-connected', label: 'Pi' } + ] + const devboxState = { targetId: 'devbox', status: 'connected' } + mocks.sshStates.set('devbox', devboxState) + const listing = createRuntimeEnvironmentMobileRelayHosts().list() + expect(listing.sshTargetLabels).toEqual( + new Map([ + ['devbox', 'Dev Box'], + ['never-connected', 'Pi'] + ]) + ) + expect(listing.sshConnectionStates).toEqual(new Map([['devbox', devboxState]])) + expect(listing.environments).toEqual([ + { + id: 'env-1', + name: 'VM', + source: undefined, + orcadDeployment: undefined, + pairingRevision: 7, + runtimeId: 'runtime-a' + }, + { + id: 'env-2', + name: 'Box', + source: 'ephemeral-vm', + orcadDeployment: undefined, + pairingRevision: 2, + runtimeId: null + } + ]) + expect([...listing.statusByEnvironmentId.keys()]).toEqual(['env-1']) + expect(listing.statusByEnvironmentId.get('env-1')).toMatchObject({ status, checkedAt: 5 }) + expect(mocks.resolveManaged).not.toHaveBeenCalled() }) it('resolves a configured server by exact id, fenced by pairing revision and runtime identity', async () => { @@ -63,7 +143,36 @@ describe('runtime environment mobile relay hosts', () => { mocks.call.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: { runtimeId: 'r' } }) const host = (await hosts.resolve('env-1'))! await hosts.call(host, 'status.get', undefined) - expect(mocks.call).toHaveBeenCalledWith('/user-data', 'env-1', 'status.get', undefined) + expect(mocks.call).toHaveBeenLastCalledWith( + '/user-data', + 'env-1', + 'status.get', + undefined, + undefined, + undefined, + undefined, + undefined + ) + // A bounded call refused by the transport once the server was re-paired or replaced. + await hosts.call( + { environmentId: 'env-1' }, + 'worktree.ps', + {}, + { + timeoutMs: 5_000, + expected: { pairingRevision: 7, runtimeId: 'runtime-a' } + } + ) + expect(mocks.call).toHaveBeenLastCalledWith( + '/user-data', + 'env-1', + 'worktree.ps', + {}, + 5_000, + 7, + undefined, + { expectedEnvironmentRuntimeId: 'runtime-a' } + ) const retired: string[] = [] const stop = hosts.onEnvironmentRetired((id) => retired.push(id)) diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts index a18bfbfa37f5..4cd7f9f0d756 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts @@ -1,8 +1,18 @@ import { app } from 'electron' import { listEnvironments } from '../../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { + runtimeEnvironmentStatusFromSnapshot, + type RuntimeEnvironmentStatus +} from '../../shared/runtime-host-status' import type { MobileDesktopRelayHosts } from '../runtime/mobile-desktop-relay/mobile-desktop-relay-hosts' import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' +import type { SshConnectionState } from '../../shared/ssh-types' +import { getRegisteredSshState, listRegisteredSshTargets } from '../ssh/ssh-target-registry' +import { getRuntimeEnvironmentStatusSnapshots } from './runtime-environment-request-connections' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' const retirementListeners = new Set<(environmentId: string) => void>() @@ -19,6 +29,38 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo // Why userData: configured servers live where the runtime-environment handlers keep them. const userDataPath = app.getPath('userData') return { + list: () => { + const environments = listEnvironments(userDataPath) + const statusByEnvironmentId = new Map() + for (const snapshot of getRuntimeEnvironmentStatusSnapshots()) { + const environment = environments.find((entry) => entry.id === snapshot.environmentId) + // Why: as in the renderer, a snapshot from an earlier pairing says nothing about this one. + if (environment && pairingRevision(environment) === snapshot.pairingRevision) { + statusByEnvironmentId.set(environment.id, runtimeEnvironmentStatusFromSnapshot(snapshot)) + } + } + const sshTargets = listRegisteredSshTargets() + const sshConnectionStates = new Map() + for (const target of sshTargets) { + const state = getRegisteredSshState(target.id) + if (state) { + sshConnectionStates.set(target.id, state) + } + } + return { + sshTargetLabels: new Map(sshTargets.map((target) => [target.id, target.label])), + sshConnectionStates, + environments: environments.map((environment) => ({ + id: environment.id, + name: environment.name, + source: environment.source, + orcadDeployment: environment.orcadDeployment, + pairingRevision: pairingRevision(environment), + runtimeId: environment.runtimeId + })), + statusByEnvironmentId + } + }, resolve: async (environmentId) => { if (!listEnvironments(userDataPath).some((entry) => entry.id === environmentId)) { return null @@ -26,15 +68,34 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo const environment = await resolveManagedRuntimeEnvironment(userDataPath, environmentId) return { environmentId, - fence: `${environment.pairingRevision ?? environment.createdAt}\0${environment.runtimeId}`, + fence: relayHostFence(environment), pairing: getPreferredPairingOffer(environment) } }, - call: (host, method, params) => - callRuntimeEnvironment(userDataPath, host.environmentId, method, params), + call: (host, method, params, options) => + callRuntimeEnvironment( + userDataPath, + host.environmentId, + method, + params, + options?.timeoutMs, + options?.expected?.pairingRevision, + undefined, + options?.expected?.runtimeId + ? { expectedEnvironmentRuntimeId: options.expected.runtimeId } + : undefined + ), onEnvironmentRetired: (listener) => { retirementListeners.add(listener) return () => retirementListeners.delete(listener) } } } + +function pairingRevision(environment: KnownRuntimeEnvironment): number { + return environment.pairingRevision ?? environment.createdAt +} + +function relayHostFence(environment: KnownRuntimeEnvironment): string { + return `${pairingRevision(environment)}\0${environment.runtimeId}` +} diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts index ff7727f8c6e9..efcd916cfde9 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts @@ -1,5 +1,8 @@ import type { PairingOffer } from '../../../shared/pairing' +import type { RuntimeEnvironmentSource } from '../../../shared/runtime-environments' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { SshConnectionState } from '../../../shared/ssh-types' /** A configured server the desktop can relay to. */ export type MobileDesktopRelayHost = { @@ -10,15 +13,42 @@ export type MobileDesktopRelayHost = { pairing: PairingOffer } +/** A configured server as the desktop's sidebar sees it, without contacting it. */ +export type MobileDesktopRelayHostListing = { + id: string + name: string + source?: RuntimeEnvironmentSource + orcadDeployment?: { sshTargetId: string } | null + pairingRevision: number + runtimeId: string | null +} + +/** Bounds a call; `expected` refuses it once the server was re-paired or its runtime changed. */ +export type MobileDesktopRelayCallOptions = { + timeoutMs?: number + expected?: Pick +} + /** The desktop's configured servers, as the relay sees them. */ export type MobileDesktopRelayHosts = { + /** + * Every configured server, with the desktop's own last status for those it has one for, and its + * SSH targets, which pair a managed server with the SSH host it runs on. + */ + list: () => { + environments: MobileDesktopRelayHostListing[] + statusByEnvironmentId: ReadonlyMap + sshTargetLabels: ReadonlyMap + sshConnectionStates: ReadonlyMap + } /** Null when the id names no configured server; a phone-supplied endpoint is never used. */ resolve: (environmentId: string) => Promise /** A call as the desktop itself, over its own connection to the server. */ call: ( - host: MobileDesktopRelayHost, + host: Pick, method: string, - params: unknown + params: unknown, + options?: MobileDesktopRelayCallOptions ) => Promise> /** Fires when a server is removed, re-paired or disconnected; returns an unsubscribe. */ onEnvironmentRetired: (listener: (environmentId: string) => void) => () => void diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts index e21d2b3b3d26..2a3e21325156 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts @@ -144,8 +144,19 @@ describe('mobile desktop relay: phone -> desktop -> server', () => { const registry = host.server.getDeviceRegistry()! const children = () => registry.listDelegatedMobileDevices(desktopOnHost.pairedDeviceId!) const syncedNames: string[][] = [] - const state = { capable: true, retire: (_id: string) => {} } + const retirementListeners = new Set<(environmentId: string) => void>() + const state = { + capable: true, + retire: (environmentId: string) => + retirementListeners.forEach((listener) => listener(environmentId)) + } const hosts: MobileDesktopRelayHosts = { + list: () => ({ + environments: [], + statusByEnvironmentId: new Map(), + sshTargetLabels: new Map(), + sshConnectionStates: new Map() + }), resolve: async (environmentId) => environmentId === 'env-1' ? { environmentId, fence: 'pairing-1', pairing: desktopOnHost } @@ -165,8 +176,8 @@ describe('mobile desktop relay: phone -> desktop -> server', () => { : response }, onEnvironmentRetired: (listener) => { - state.retire = listener - return () => {} + retirementListeners.add(listener) + return () => retirementListeners.delete(listener) } } desktop.server.setMobileDesktopRelayHosts(hosts) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts index 46e9a86c5f2b..93f00849c4bc 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts @@ -56,6 +56,12 @@ function relayWithPhone( const syncCalls: unknown[] = [] const server = { capable: true } const hosts: MobileDesktopRelayHosts = { + list: () => ({ + environments: [], + statusByEnvironmentId: new Map(), + sshTargetLabels: new Map(), + sshConnectionStates: new Map() + }), resolve: async (environmentId) => ({ environmentId, fence: 'f', diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts new file mode 100644 index 000000000000..1f2645ae13d4 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts @@ -0,0 +1,203 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract' +import { parsePairingCode, type PairingOffer } from '../../../shared/pairing' +import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' +import { sendRemoteRuntimeRequest } from '../../../shared/remote-runtime-client' +import { + runtimeEnvironmentStatusFromSnapshot, + type RuntimeHostStatusSnapshot +} from '../../../shared/runtime-host-status' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { + authenticateMobileWsSession, + createEncryptedWsResponseReader, + sendEncryptedWsRequest +} from '../runtime-rpc-mobile-ws-test-harness' +import { makeStore } from '../runtime-rpc-worktree-store-fixtures' +import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' + +vi.mock('../../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +type Contact = 'live' | 'unreachable' + +describe('mobile relay hosts: phone -> desktop lists the servers it shows', () => { + const cleanups: (() => Promise | void)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + async function startRuntime() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store carries the surface these RPCs read, as in the sibling relay tests. + const runtime = new OrcaRuntimeService(makeStore() as never) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-relay-hosts-')), + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + return server + } + + function pairingUrl(server: OrcaRuntimeRpcServer, scope: 'mobile' | 'runtime'): string { + const offer = server.createPairingOffer({ address: '127.0.0.1', name: scope, scope }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + return offer.pairingUrl + } + + /** The desktop's status snapshot for a server: what its status owner last heard. */ + function snapshot( + environmentId: string, + contact: Contact, + capable: boolean + ): RuntimeHostStatusSnapshot { + return { + environmentId, + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: { + runtimeId: environmentId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + capabilities: capable ? [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] : [] + }, + verification: contact === 'live' ? 'verified' : 'unavailable', + transport: contact === 'live' ? 'ready' : 'disconnected' + } + } + + async function startTopology() { + const box = await startRuntime() + const old = await startRuntime() + const desktop = await startRuntime() + const servers = new Map([ + [ + 'box', + { name: 'Box', pairing: parsePairingCode(pairingUrl(box, 'runtime'))!, capable: true } + ], + [ + 'old', + { name: 'ThinkPad', pairing: parsePairingCode(pairingUrl(old, 'runtime'))!, capable: false } + ] + ]) + const contact = new Map([ + ['box', 'live'], + ['old', 'live'] + ]) + const hosts: MobileDesktopRelayHosts = { + list: () => ({ + environments: [...servers].map(([id, { name }]) => ({ + id, + name, + pairingRevision: 1, + runtimeId: id + })), + sshTargetLabels: new Map(), + sshConnectionStates: new Map(), + statusByEnvironmentId: new Map( + [...servers].map(([id, { capable }]) => [ + id, + runtimeEnvironmentStatusFromSnapshot(snapshot(id, contact.get(id)!, capable)) + ]) + ) + }), + resolve: async (environmentId) => { + const server = servers.get(environmentId) + return server ? { environmentId, fence: 'pairing-1', pairing: server.pairing } : null + }, + call: (host, method, params) => + sendRemoteRuntimeRequest(servers.get(host.environmentId)!.pairing, method, params, 5_000), + onEnvironmentRetired: () => () => {} + } + desktop.setMobileDesktopRelayHosts(hosts) + const session = await authenticateMobileWsSession(pairingUrl(desktop, 'mobile')) + const replies = createEncryptedWsResponseReader(session) + cleanups.push(() => { + replies.dispose() + session.ws.close() + }) + let sequence = 0 + const phoneCall = async (method: string, params?: unknown) => { + const id = `phone-${++sequence}` + sendEncryptedWsRequest(session, { id, method, params }) + return z.object({ ok: z.literal(true), result: z.unknown() }).parse(await replies.next(id)) + .result + } + return { box, contact, phoneCall } + } + + it('lists rows live through the desktop, then its last rows once a server is unreachable', async () => { + const { box, contact, phoneCall } = await startTopology() + + await expect(phoneCall('mobileRelay.hosts.list')).resolves.toEqual({ + hosts: [ + { hostId: 'runtime:box', label: 'Box', health: 'available', relay: 'ready' }, + { hostId: 'runtime:old', label: 'ThinkPad', health: 'available', relay: 'update-needed' } + ] + }) + const warm = await phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:box' }) + expect(warm).toMatchObject({ + worktrees: [ + { worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:box', branch: 'feature/foo' } + ] + }) + + // The update-needed server still answers the desktop, so its rows are live. + await expect( + phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:old' }) + ).resolves.toMatchObject({ + worktrees: [{ worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:old' }] + }) + + await box.stop() + contact.set('box', 'unreachable') + await expect(phoneCall('mobileRelay.hosts.list')).resolves.toMatchObject({ + hosts: [{ hostId: 'runtime:box', relay: 'unavailable' }, { hostId: 'runtime:old' }] + }) + // The last rows exactly as fetched, provenance kept. + await expect( + phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:box' }) + ).resolves.toEqual(warm) + }) + + it('answers an empty list on a desktop that relays nowhere', async () => { + const desktop = await startRuntime() + const session = await authenticateMobileWsSession(pairingUrl(desktop, 'mobile')) + const replies = createEncryptedWsResponseReader(session) + cleanups.push(() => { + replies.dispose() + session.ws.close() + }) + sendEncryptedWsRequest(session, { id: 'list', method: 'mobileRelay.hosts.list' }) + await expect(replies.next('list')).resolves.toMatchObject({ ok: true, result: { hosts: [] } }) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts new file mode 100644 index 000000000000..8759eee02ca1 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it, vi } from 'vitest' +import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract' +import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' +import { + runtimeEnvironmentStatusFromSnapshot, + type RuntimeHostStatusSnapshot +} from '../../../shared/runtime-host-status' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { SshConnectionState } from '../../../shared/ssh-types' +import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' +import { MobileRelayHostCatalog } from './mobile-relay-host-catalog' + +function answer(capable: boolean): RuntimeStatus { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + capabilities: capable ? [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] : [] + } +} + +function snapshot( + environmentId: string, + contact: 'live' | 'unreachable' | 'never-answered', + capable = true +): RuntimeHostStatusSnapshot { + return { + environmentId, + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: contact === 'never-answered' ? null : answer(capable), + verification: contact === 'live' ? 'verified' : 'unavailable', + transport: contact === 'live' ? 'ready' : 'disconnected' + } +} + +function psReply(rows: Record[]): RuntimeRpcResponse { + return { + id: 'ps', + ok: true, + result: { worktrees: rows, totalCount: rows.length, truncated: false }, + _meta: { runtimeId: 'runtime-a' } + } +} + +function fakeHosts( + environments: { + id: string + name: string + snapshot: RuntimeHostStatusSnapshot + orcadDeployment?: { sshTargetId: string } + runtimeId?: string | null + }[] +) { + const state = { + environments, + pairingRevision: 1, + retire: (_environmentId: string) => {}, + sshTargetLabels: new Map(), + sshConnectionStates: new Map() + } + const call = vi.fn() + const hosts: MobileDesktopRelayHosts = { + list: () => ({ + environments: state.environments.map(({ id, name, orcadDeployment, runtimeId }) => ({ + id, + name, + orcadDeployment, + pairingRevision: state.pairingRevision, + runtimeId: runtimeId === undefined ? 'runtime-a' : runtimeId + })), + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + statusByEnvironmentId: new Map( + state.environments.map((entry) => [ + entry.id, + runtimeEnvironmentStatusFromSnapshot(entry.snapshot) + ]) + ) + }), + resolve: async (environmentId) => ({ + environmentId, + fence: 'unused', + pairing: { v: 2, endpoint: 'ws://server', deviceToken: 'desktop', publicKeyB64: 'k' } + }), + call, + onEnvironmentRetired: (listener) => { + state.retire = listener + return () => {} + } + } + return { hosts, call, state } +} + +function catalog(hosts: MobileDesktopRelayHosts, labels = new Map()) { + return { catalog: new MobileRelayHostCatalog({ hosts, hostLabelOverrides: () => labels }) } +} + +describe('mobile relay host catalog', () => { + it("lists every configured server with the desktop's health and whether it can relay", async () => { + const { hosts, call } = fakeHosts([ + { id: 'ready', name: 'Box', snapshot: snapshot('ready', 'live') }, + { id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) }, + { id: 'down', name: 'VM', snapshot: snapshot('down', 'unreachable') }, + { id: 'old-down', name: 'Pi', snapshot: snapshot('old-down', 'unreachable', false) }, + { id: 'new', name: 'Fresh', snapshot: snapshot('new', 'never-answered') } + ]) + call.mockResolvedValue(psReply([])) + const { catalog: hostCatalog } = catalog(hosts, new Map([['runtime:ready', 'Build box']])) + + expect(hostCatalog.list().hosts).toEqual([ + { hostId: 'runtime:ready', label: 'Build box', health: 'available', relay: 'ready' }, + { hostId: 'runtime:old', label: 'ThinkPad', health: 'available', relay: 'update-needed' }, + { hostId: 'runtime:down', label: 'VM', health: 'connecting', relay: 'unavailable' }, + // A build's capabilities outlive contact, so an old server stays update-needed offline. + { hostId: 'runtime:old-down', label: 'Pi', health: 'connecting', relay: 'update-needed' }, + { hostId: 'runtime:new', label: 'Fresh', health: 'connecting', relay: 'unavailable' } + ]) + // Listing reads what the desktop already knows; it never contacts a server. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(call).not.toHaveBeenCalled() + }) + + it("lists a managed server once, under its SSH host's name and rename, as the sidebar does", async () => { + const { hosts, state } = fakeHosts([ + { + id: 'managed', + name: 'orcad on devbox', + snapshot: snapshot('managed', 'live'), + orcadDeployment: { sshTargetId: 'devbox' } + } + ]) + state.sshTargetLabels.set('devbox', 'Dev Box') + expect(catalog(hosts).catalog.list().hosts).toEqual([ + { hostId: 'runtime:managed', label: 'Dev Box', health: 'available', relay: 'ready' } + ]) + const renamed = catalog(hosts, new Map([['ssh:devbox', 'Build box']])).catalog + expect(renamed.list().hosts.map((host) => host.label)).toEqual(['Build box']) + }) + + it('does not list a managed server while its SSH host is reached over the SSH relay', () => { + const { hosts, state } = fakeHosts([ + { + id: 'managed', + name: 'orcad on devbox', + snapshot: snapshot('managed', 'live'), + orcadDeployment: { sshTargetId: 'devbox' } + }, + { id: 'other', name: 'Box', snapshot: snapshot('other', 'live') } + ]) + state.sshTargetLabels.set('devbox', 'Dev Box') + state.sshConnectionStates.set('devbox', { + targetId: 'devbox', + status: 'connected', + error: null, + reconnectAttempt: 0, + managedServer: { kind: 'relay', reason: 'relay_terminals_live' } + }) + // Its workspaces are the SSH host's, which the desktop already lists to the phone itself. + expect( + catalog(hosts) + .catalog.list() + .hosts.map((host) => host.hostId) + ).toEqual(['runtime:other']) + }) + + it('encodes an environment id into the host id the desktop uses', () => { + const { hosts } = fakeHosts([ + { id: 'a b', name: 'Spaced', snapshot: snapshot('a b', 'unreachable') } + ]) + expect(catalog(hosts).catalog.list().hosts[0]?.hostId).toBe('runtime:a%20b') + }) + + it("serves a server's rows fetched as the desktop, re-stamped with the server's host id", async () => { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValue( + psReply([ + { worktreeId: 'w-local', hostId: 'local', status: 'working' }, + { worktreeId: 'w-ssh', hostId: 'ssh:devbox', status: 'inactive' }, + { worktreeId: 'w-legacy', status: 'done' } + ]) + ) + const { catalog: hostCatalog } = catalog(hosts) + + await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ + worktrees: [ + { worktreeId: 'w-local', hostId: 'runtime:env', status: 'working' }, + { worktreeId: 'w-ssh', hostId: 'runtime:env', status: 'inactive' }, + { worktreeId: 'w-legacy', hostId: 'runtime:env', status: 'done' } + ] + }) + expect(call).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: 'env' }), + 'worktree.ps', + // The phone's full-catalog limit: the server's default page would silently truncate. + { limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true }, + { timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } } + ) + }) + + it('keeps the last rows, unchanged, once the server stops answering', async () => { + const { hosts, call, state } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local', status: 'working' }])) + const { catalog: hostCatalog } = catalog(hosts) + await hostCatalog.worktrees('runtime:env') + + state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }] + const offline = await hostCatalog.worktrees('runtime:env') + // Never re-fetched while the desktop itself cannot reach it, and never rewritten as idle. + expect(call).toHaveBeenCalledTimes(1) + expect(offline).toEqual({ + worktrees: [{ worktreeId: 'w', hostId: 'runtime:env', status: 'working' }] + }) + + // A failed fetch on a server that looks reachable also serves the last rows. + state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }] + call.mockRejectedValueOnce(new Error('socket closed')) + await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual(offline) + expect(call).toHaveBeenCalledTimes(2) + }) + + it('omits rows mid-delete, as a phone asking the server itself would never see them', async () => { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValue({ + id: 'ps', + ok: true, + result: { + worktrees: [ + { worktreeId: 'kept', hostId: 'local' }, + { worktreeId: 'deleting', hostId: 'local', removing: true } + ], + totalCount: 5, + truncated: true + }, + _meta: { runtimeId: 'runtime-a' } + }) + await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toEqual({ + worktrees: [{ worktreeId: 'kept', hostId: 'runtime:env' }] + }) + }) + + it('lists an update-needed server live, since the desktop itself can still reach it', async () => { + const { hosts, call } = fakeHosts([ + { id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) } + ]) + call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }])) + await expect(catalog(hosts).catalog.worktrees('runtime:old')).resolves.toEqual({ + worktrees: [{ worktreeId: 'w', hostId: 'runtime:old' }] + }) + }) + + it('has no rows for a server never fetched, an unknown host, or rows from before a re-pair', async () => { + const { hosts, call, state } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') } + ]) + const { catalog: hostCatalog } = catalog(hosts) + await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null }) + await expect(hostCatalog.worktrees('runtime:other')).resolves.toEqual({ worktrees: null }) + await expect(hostCatalog.worktrees('ssh:env')).resolves.toEqual({ worktrees: null }) + + state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }] + call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }])) + await hostCatalog.worktrees('runtime:env') + state.pairingRevision = 2 + state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }] + await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null }) + }) + + it('has no rows when re-paired mid-fetch, and the next poll fetches the new server afresh', async () => { + const { hosts, call, state } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + let answerOldFetch: (response: RuntimeRpcResponse) => void = () => {} + call.mockReturnValueOnce(new Promise((resolve) => (answerOldFetch = resolve))) + const { catalog: hostCatalog } = catalog(hosts) + const rows = hostCatalog.worktrees('runtime:env') + await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1)) + state.pairingRevision = 2 + call.mockResolvedValueOnce(psReply([{ worktreeId: 'new-server-row', hostId: 'local' }])) + // Never joins the old server's fetch, still pending here. + const next = hostCatalog.worktrees('runtime:env') + await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(2)) + expect(call).toHaveBeenLastCalledWith(expect.anything(), 'worktree.ps', expect.anything(), { + timeoutMs: 5_000, + expected: { pairingRevision: 2, runtimeId: 'runtime-a' } + }) + await expect(next).resolves.toMatchObject({ worktrees: [{ worktreeId: 'new-server-row' }] }) + answerOldFetch(psReply([{ worktreeId: 'old-server-row', hostId: 'local' }])) + await expect(rows).resolves.toEqual({ worktrees: null }) + }) + + it("forgets a removed server's rows, even ones its last fetch delivers after removal", async () => { + const { hosts, call, state } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + let answerFetch: (response: RuntimeRpcResponse) => void = () => {} + call.mockReturnValue(new Promise((resolve) => (answerFetch = resolve))) + const { catalog: hostCatalog } = catalog(hosts) + const rows = hostCatalog.worktrees('runtime:env') + await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1)) + const removed = state.environments + state.environments = [] + state.retire('env') + answerFetch(psReply([{ worktreeId: 'w', hostId: 'local' }])) + await expect(rows).resolves.toEqual({ worktrees: null }) + expect(hostCatalog.list().hosts).toEqual([]) + // Added back under the same id and identity: the late rows were pruned, not kept. + state.environments = [{ ...removed[0]!, snapshot: snapshot('env', 'unreachable') }] + await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null }) + }) + + it('keeps the last rows for a server manually disconnected but still configured', async () => { + const { hosts, call, state } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }])) + const { catalog: hostCatalog } = catalog(hosts) + await hostCatalog.worktrees('runtime:env') + // A manual disconnect retires the desktop's transport and its status owner. + state.environments = [ + { + id: 'env', + name: 'Box', + snapshot: { ...snapshot('env', 'unreachable'), retired: true, verification: 'blocked' } + } + ] + state.retire('env') + await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ + worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }] + }) + expect(call).toHaveBeenCalledTimes(1) + }) + + it('lists a server whose runtime id the desktop has not recorded yet', async () => { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live'), runtimeId: null } + ]) + call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }])) + await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toMatchObject({ + worktrees: [{ worktreeId: 'w' }] + }) + expect(call).toHaveBeenCalledWith(expect.anything(), 'worktree.ps', expect.anything(), { + timeoutMs: 5_000, + expected: { pairingRevision: 1, runtimeId: null } + }) + }) + + it('answers with the last rows when the server never answers the fetch', async () => { + vi.useFakeTimers() + try { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValueOnce(psReply([{ worktreeId: 'w', hostId: 'local' }])) + const { catalog: hostCatalog } = catalog(hosts) + await hostCatalog.worktrees('runtime:env') + call.mockReturnValue(new Promise(() => {})) + let settled = false + const rows = hostCatalog.worktrees('runtime:env').finally(() => (settled = true)) + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(rows).resolves.toEqual({ + worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }] + }) + } finally { + vi.useRealTimers() + } + }) + + it('shares one in-flight fetch per server between concurrent rows requests', async () => { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + let answerFetch: (response: RuntimeRpcResponse) => void = () => {} + call.mockReturnValue(new Promise((resolve) => (answerFetch = resolve))) + const { catalog: hostCatalog } = catalog(hosts) + const first = hostCatalog.worktrees('runtime:env') + const second = hostCatalog.worktrees('runtime:env') + await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1)) + answerFetch(psReply([{ worktreeId: 'w', hostId: 'local' }])) + const rows = { worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }] } + await expect(Promise.all([first, second])).resolves.toEqual([rows, rows]) + expect(call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts new file mode 100644 index 000000000000..90fb0a5bab3c --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -0,0 +1,212 @@ +import { z } from 'zod' +import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract' +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' +import { pickerExecutionHosts } from '../../../shared/managed-orcad-execution-host' +import type { + MobileRelayHost, + MobileRelayHostRelay, + MobileRelayHostsListResult, + MobileRelayHostWorktreesResult, + MobileRelayServerWorktreeRow +} from '../../../shared/mobile-relay-hosts-contract' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { + MobileDesktopRelayHostListing, + MobileDesktopRelayHosts +} from './mobile-desktop-relay-hosts' + +// Why the phone's own full-catalog limit: the server's default page is 200 and nothing pages on. +const SERVER_WORKTREE_LIMIT = 10_000 +// Why well under the phone's 30s request timeout: a hung server must answer with its last rows. +const SERVER_FETCH_TIMEOUT_MS = 5_000 + +const WorktreePsReplySchema = z.object({ worktrees: z.array(z.looseObject({})) }) + +type CachedServerWorktrees = { + fence: string + worktrees: MobileRelayServerWorktreeRow[] +} + +type DescribedHost = MobileRelayHost & { + environmentId: string + identity: Pick +} + +/** + * The configured servers the desktop shows, for the phone: health from the desktop's own status + * (the sidebar's mapping), and each server's workspace list as the desktop itself last fetched it, + * so a server that is offline or too old to relay to still lists. Replies relayed to a phone are + * never read here. + */ +export class MobileRelayHostCatalog { + private readonly cached = new Map() + // Keyed by server identity, so a poll after a re-pair never joins the old server's fetch. + private readonly refreshing = new Map>() + + constructor( + private readonly options: { + hosts: MobileDesktopRelayHosts + hostLabelOverrides: () => ReadonlyMap + } + ) {} + + list(): MobileRelayHostsListResult { + return { + hosts: this.describe().map(({ hostId, label, health, relay }) => ({ + hostId, + label, + health, + relay + })) + } + } + + async worktrees(hostId: string): Promise { + const parsed = parseExecutionHostId(hostId) + const host = + parsed?.kind === 'runtime' + ? this.describe().find((entry) => entry.environmentId === parsed.environmentId) + : undefined + if (!host) { + return { worktrees: null } + } + if (host.health === 'available') { + await withinFetchBound(this.refresh(host)) + } + const cached = this.cached.get(host.environmentId) + const current = this.options.hosts + .list() + .environments.find((environment) => environment.id === host.environmentId) + // Why the identity read after the fetch: one answered after a re-pair stored the old server's rows. + if (!cached || !current || cached.fence !== fenceOf(current)) { + return { worktrees: null } + } + // Why: the desktop asks with background-removal support; a phone asking itself never sees these rows. + return { worktrees: cached.worktrees.filter((row) => row.removing !== true) } + } + + private describe(): DescribedHost[] { + const { environments, statusByEnvironmentId, sshTargetLabels, sshConnectionStates } = + this.options.hosts.list() + const identities = new Map(environments.map((environment) => [environment.id, environment])) + // Why lazily, not on retirement: a disconnected server keeps its rows; only a removed one loses them. + for (const environmentId of this.cached.keys()) { + if (!identities.has(environmentId)) { + this.cached.delete(environmentId) + } + } + // Why the desktop's picker rows: a managed server folds into its SSH host as the sidebar shows it. + return pickerExecutionHosts( + buildExecutionHostRegistry({ + repos: [], + settings: null, + hostSource: 'configured-only', + sshTargetLabels, + sshConnectionStates, + runtimeEnvironments: environments, + runtimeStatusByEnvironmentId: statusByEnvironmentId, + hostLabelOverrides: this.options.hostLabelOverrides() + }) + ).flatMap((entry) => { + const parsed = parseExecutionHostId(entry.id) + const identity = parsed?.kind === 'runtime' ? identities.get(parsed.environmentId) : undefined + if (parsed?.kind !== 'runtime' || !identity) { + return [] + } + const answer = lastVerifiedRuntimeStatus(statusByEnvironmentId.get(parsed.environmentId)) + return [ + { + hostId: parsed.id, + environmentId: parsed.environmentId, + identity: { pairingRevision: identity.pairingRevision, runtimeId: identity.runtimeId }, + label: entry.label, + health: entry.health, + relay: relayVerdict(entry.health, answer) + } + ] + }) + } + + private refresh(host: DescribedHost): Promise { + const key = `${host.environmentId}\0${fenceOf(host.identity)}` + const inFlight = this.refreshing.get(key) + if (inFlight) { + return inFlight + } + const refresh = this.fetchAsDesktop(host).finally(() => this.refreshing.delete(key)) + this.refreshing.set(key, refresh) + return refresh + } + + private async fetchAsDesktop(host: DescribedHost): Promise { + try { + const response = await this.options.hosts.call( + host, + 'worktree.ps', + { limit: SERVER_WORKTREE_LIMIT, supportsWorktreeVisibilitySourceDefaults: true }, + // Why the transport bound too: it ends the hung call, so the next poll fetches afresh instead of joining it. + { timeoutMs: SERVER_FETCH_TIMEOUT_MS, expected: host.identity } + ) + const reply = response.ok ? WorktreePsReplySchema.safeParse(response.result) : null + if (!reply?.success) { + return + } + this.cached.set(host.environmentId, { + // Why the identity taken before the fetch: the call's own check cannot see a re-pair after dispatch. + fence: fenceOf(host.identity), + worktrees: stampServerWorktreeRows(host.environmentId, reply.data.worktrees) + }) + } catch { + // An unreachable server keeps its last rows. + } + } +} + +function fenceOf(identity: DescribedHost['identity']): string { + return `${identity.pairingRevision}\0${identity.runtimeId}` +} + +/** Settles once the bound passes; the call's own timeout starts only after the desktop's per-server queue. */ +async function withinFetchBound(refresh: Promise): Promise { + let timer: ReturnType | undefined + const bound = new Promise((resolve) => { + timer = setTimeout(resolve, SERVER_FETCH_TIMEOUT_MS) + }) + try { + return await Promise.race([refresh, bound]) + } finally { + clearTimeout(timer) + } +} + +function relayVerdict( + health: MobileRelayHost['health'], + answer: RuntimeStatus | null +): MobileRelayHostRelay { + if (!answer) { + return 'unavailable' + } + // Why: a build's capabilities do not expire, so an old server stays update-needed while offline. + if (!(answer.capabilities ?? []).includes(DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY)) { + return 'update-needed' + } + return health === 'available' ? 'ready' : 'unavailable' +} + +/** + * A server's rows name hosts relative to that server (`local`, its own `ssh:` targets). Like the + * desktop sidebar, the phone shows every one of them under the server, so all become its host id. + */ +function stampServerWorktreeRows( + environmentId: string, + rows: readonly Row[] +): (Row & { hostId: `runtime:${string}` })[] { + const hostId = toRuntimeExecutionHostId(environmentId) + return rows.map((row) => ({ ...row, hostId })) +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index f58b10313bba..e8b4caecf7bd 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -9,6 +9,10 @@ import type { PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { + MobileRelayHostsListResult, + MobileRelayHostWorktreesResult +} from '../../../shared/mobile-relay-hosts-contract' import type { DelegatedPhone, DelegatedMobileDeviceSyncResult @@ -121,6 +125,11 @@ export type RpcContext = { // Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage. authenticatedCallerFingerprint?: string pairing?: PairingRpcContext + // Why: set only on a desktop that relays; it lists the configured servers it shows. + mobileRelayHosts?: { + list(): MobileRelayHostsListResult + worktrees(hostId: string): Promise + } // Why: set only for an authenticated runtime-scope device and bound to it, so it manages only its own phones. delegatedMobileDevices?: { sync(phones: readonly DelegatedPhone[]): DelegatedMobileDeviceSyncResult diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 9c6b2447be2b..c9c8fc404b7a 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -16,6 +16,7 @@ export type RpcDispatchStreamingOptions = Pick< | 'clientCapabilities' | 'updateClientCapabilities' | 'pairing' + | 'mobileRelayHosts' | 'delegatedMobileDevices' | 'sendBinary' | 'registerBinaryStreamHandler' @@ -49,6 +50,7 @@ export function rpcContextFromTransport( updateClientCapabilities: options?.updateClientCapabilities, authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, pairing: options?.pairing, + mobileRelayHosts: options?.mobileRelayHosts, delegatedMobileDevices: options?.delegatedMobileDevices, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index c5bea59e138d..dc1ceca16725 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -47,6 +47,7 @@ import { MOBILE_WEB_BUNDLE_METHODS } from './mobile-web-bundle' import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities' import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' +import { MOBILE_RELAY_HOSTS_METHODS } from './mobile-relay-hosts' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' @@ -120,5 +121,6 @@ export const ALL_RPC_METHODS = [ ...CLIENT_UI_METHODS, ...EMULATOR_METHODS, ...PAIRING_METHODS, + ...MOBILE_RELAY_HOSTS_METHODS, ...UPDATER_METHODS ] diff --git a/src/main/runtime/rpc/methods/mobile-relay-hosts.ts b/src/main/runtime/rpc/methods/mobile-relay-hosts.ts new file mode 100644 index 000000000000..a801dcadebaf --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-relay-hosts.ts @@ -0,0 +1,23 @@ +import { defineMethod } from '../core' +import { + MOBILE_RELAY_HOST_WORKTREES_METHOD, + MOBILE_RELAY_HOSTS_LIST_METHOD, + MobileRelayHostWorktreesParamsSchema +} from '../../../../shared/mobile-relay-hosts-contract' + +export const MOBILE_RELAY_HOSTS_METHODS = [ + defineMethod({ + name: MOBILE_RELAY_HOSTS_LIST_METHOD, + permission: 'workspace', + params: null, + // Why empty rather than an error: a desktop that relays nowhere has no servers to show. + handler: (_params, ctx) => ctx.mobileRelayHosts?.list() ?? { hosts: [] } + }), + defineMethod({ + name: MOBILE_RELAY_HOST_WORKTREES_METHOD, + permission: 'workspace', + params: MobileRelayHostWorktreesParamsSchema, + handler: async (params, ctx) => + (await ctx.mobileRelayHosts?.worktrees(params.hostId)) ?? { worktrees: null } + }) +] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index 14ac2c5dcf79..2534aaa79ac4 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -12,6 +12,9 @@ export const PAIRED_DESKTOP_METHODS = [ 'notifications.unsubscribe', 'pairing.getEndpoints', 'pairing.provisionRelay', + // The configured servers the desktop shows, and their workspace lists as it last fetched them. + 'mobileRelay.hosts.list', + 'mobileRelay.hosts.worktrees', 'speech.dictation.cancel', 'speech.dictation.chunk', 'speech.dictation.finish', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 13538acc18d3..66c8e92e2df1 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -10,6 +10,8 @@ import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint' import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure' import { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay' import type { MobileDesktopRelayHosts } from '../mobile-desktop-relay/mobile-desktop-relay-hosts' +import { MobileRelayHostCatalog } from '../mobile-desktop-relay/mobile-relay-host-catalog' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { DELEGATED_PHONE_NAME_MAX_CHARS } from '../../../shared/delegated-mobile-device-contract' import { allocateTerminalSubscriptionStreamId } from '../rpc/methods/terminal/terminal-subscription-stream-id' import { @@ -98,6 +100,12 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { allocateStreamId: allocateTerminalSubscriptionStreamId }) : null + this.mobileRelayHostCatalog = hosts + ? new MobileRelayHostCatalog({ + hosts, + hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()) + }) + : null } private listRelayedPhones(): { phoneKey: string; name: string }[] { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 64b44e58733f..88dedf09dde5 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -11,6 +11,7 @@ import type { RuntimeHostDescriptor } from '../../../shared/runtime-host-descrip import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import type { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay' +import type { MobileRelayHostCatalog } from '../mobile-desktop-relay/mobile-relay-host-catalog' import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox' import { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { RuntimeBinaryMessageRouter } from '../runtime-binary-message-router' @@ -73,8 +74,9 @@ export class RuntimeRpcState { // transports under the SAME wiring (see ensureMobileSocketWiring) instead of orphaning relay sockets. protected detachWebSocketWiring: (() => void) | null = null protected mobileRelayPairingProvider: MobileRelayPairingProvider | null = null - // Why: only a desktop with configured servers relays; headless serve leaves this unset. + // Why nullable: launch sets both for every runtime process; in-process test servers may not. protected mobileDesktopRelay: MobileDesktopRelay | null = null + protected mobileRelayHostCatalog: MobileRelayHostCatalog | null = null // Why: lets an automatic mint install a provider whose launch-time construction failed. protected mobileRelayPairingProviderInstaller: (() => Promise) | null = null protected mobileRelayPairingOfferQueue: Promise = Promise.resolve() diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 91236b644374..3a5160c44003 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -229,6 +229,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { } : undefined, pairing: pairingContext, + mobileRelayHosts: this.mobileRelayHostCatalog ?? undefined, delegatedMobileDevices: device.scope === 'runtime' ? { sync: (phones) => this.syncDelegatedMobileDevices(device.deviceId, phones) } diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts index d1164bc23b20..1b3c18ff640e 100644 --- a/src/renderer/src/store/slices/runtime-status-snapshot.ts +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -1,4 +1,7 @@ -import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import { + runtimeEnvironmentStatusFromSnapshot, + type RuntimeHostStatusSnapshot +} from '../../../../shared/runtime-host-status' import type { AppState } from '../types' import type { RuntimeEnvironmentStatus } from './runtime-status-types' import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' @@ -36,17 +39,11 @@ export function applyRuntimeHostStatusSnapshot( return } const entry: RuntimeEnvironmentStatus = { - snapshot, - checkedAt: snapshot.checkedAt, + ...runtimeEnvironmentStatusFromSnapshot(snapshot), connectionGeneration: previous?.connectionGeneration, - hostContactEpoch: previous?.hostContactEpoch, - status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null, - remoteControl: snapshot.remoteControl + hostContactEpoch: previous?.hostContactEpoch } if (entry.status) { - if (snapshot.remoteControl) { - entry.status = { ...entry.status, remoteControl: snapshot.remoteControl } - } state.setRuntimeEnvironmentStatus(snapshot.environmentId, entry) if (previous?.status == null) { void ensureBrowserClientHostsForRestoredPages(state) diff --git a/src/shared/host-operation-ownership.ts b/src/shared/host-operation-ownership.ts index f13b7ec1298c..0b40e5d539d2 100644 --- a/src/shared/host-operation-ownership.ts +++ b/src/shared/host-operation-ownership.ts @@ -37,6 +37,7 @@ export const HOST_OPERATION_OWNERSHIP = { linear: 'scope-picked', managedServer: 'host', markdown: 'host', + mobileRelay: 'host', mobileWeb: 'host', nativeChat: 'host', network: 'host', diff --git a/src/shared/mobile-relay-hosts-contract.ts b/src/shared/mobile-relay-hosts-contract.ts new file mode 100644 index 000000000000..975108e2dc5b --- /dev/null +++ b/src/shared/mobile-relay-hosts-contract.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' +import type { ExecutionHostHealth } from './execution-host-health' + +// Why: both are answered by the paired desktop itself; the phone lists servers the desktop shows. +export const MOBILE_RELAY_HOSTS_LIST_METHOD = 'mobileRelay.hosts.list' +export const MOBILE_RELAY_HOST_WORKTREES_METHOD = 'mobileRelay.hosts.worktrees' + +/** Whether the desktop can relay the phone to this server now; `update-needed` is the server's build. */ +export type MobileRelayHostRelay = 'ready' | 'update-needed' | 'unavailable' + +export type MobileRelayHost = { + hostId: `runtime:${string}` + label: string + health: ExecutionHostHealth + relay: MobileRelayHostRelay +} + +export type MobileRelayHostsListResult = { hosts: MobileRelayHost[] } + +export const MobileRelayHostWorktreesParamsSchema = z + .object({ hostId: z.string().min(1).max(1024) }) + .strict() + +/** One of a server's own `worktree.ps` rows (RuntimeWorktreePsSummary), carrying the server's host id. */ +export type MobileRelayServerWorktreeRow = Record & { hostId: `runtime:${string}` } + +/** + * The server's own `worktree.ps` rows as the desktop last fetched them, refreshed when the server + * answers; rows are never rewritten beyond their host id. + */ +export type MobileRelayHostWorktreesResult = { worktrees: MobileRelayServerWorktreeRow[] | null } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 5955b80dafee..d7b61f66913c 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -23,6 +23,7 @@ import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema } from '../mobile-relay-credential-contract' +import { MobileRelayHostWorktreesParamsSchema } from '../mobile-relay-hosts-contract' import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' import { OrcadMigrationSnapshotChunkRequestSchema } from '../orcad-migration-scrollback' import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' @@ -1049,6 +1050,8 @@ export const RPC_PARAMS_BY_METHOD = { 'managedServer.update': ManagedServerUpdate, 'markdown.readTab': ActivateTab, 'markdown.saveTab': SaveMarkdownTab, + 'mobileRelay.hosts.list': null, + 'mobileRelay.hosts.worktrees': MobileRelayHostWorktreesParamsSchema, 'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema, 'mobileWeb.bundle.manifest': null, 'mobileWeb.bundle.range': MobileWebBundleChunkParamsSchema, diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts index 60721849b1fc..d3df230e01b9 100644 --- a/src/shared/runtime-host-status.ts +++ b/src/shared/runtime-host-status.ts @@ -42,6 +42,22 @@ export type RuntimeEnvironmentStatus = { hostContactEpoch?: number } +/** The store entry a status snapshot stands for: its status counts only while verified and current. */ +export function runtimeEnvironmentStatusFromSnapshot( + snapshot: RuntimeHostStatusSnapshot +): RuntimeEnvironmentStatus { + const status = snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null + return { + snapshot, + checkedAt: snapshot.checkedAt, + status: + status && snapshot.remoteControl + ? { ...status, remoteControl: snapshot.remoteControl } + : status, + remoteControl: snapshot.remoteControl + } +} + /** * The last status the host actually answered with. The snapshot retains it across an * unverifiable probe, so this survives a loss of contact; the entry's own `status` does not. From ec460e50111a38323e8ab708ec20ae56daba23ac Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:51:49 -0700 Subject: [PATCH 22/58] fix(rate-limits): route Codex usage requests through the configured proxy (#26942) Use the host HTTP client for usage, reset-credit reads and redemption, keep those account requests cookie-free, and pass configured proxy settings to the native Codex probe. WSL probes keep the guest's own proxy environment. Co-authored-by: petersindex <287557875+petersindex@users.noreply.github.com> Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> --- .../rate-limits/codex-fetcher-backend.test.ts | 82 +++++++++++++++++++ .../codex-fetcher-process-contract.test.ts | 57 +++++++++++++ src/main/rate-limits/codex-fetcher.ts | 27 +++--- .../codex-rate-limit-fetch-options.ts | 3 + .../service-account-target-selection.test.ts | 30 +++++++ .../service-inactive-account-previews.test.ts | 3 + .../service-refresh-orchestration.test.ts | 3 + .../service/service-fetch-targets.ts | 1 + .../service/service-full-cycle-preparation.ts | 1 + .../service/service-inactive-accounts.ts | 1 + .../service/service-provider-cycles.ts | 1 + 11 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index b1c01a2a6e8c..74ba03959f76 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setMainHttpClient } from '../network/http-client' const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ childSpawnMock: vi.fn(), @@ -30,11 +31,92 @@ describe('Codex backend rate-limit requests', () => { }) afterEach(() => { + setMainHttpClient(null) vi.useRealTimers() vi.unstubAllGlobals() vi.restoreAllMocks() }) + it('uses the host HTTP client for usage, credit reads and redemption', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' } + }) + ) + const request = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + plan_type: 'plus', + rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } } + }) + ) + .mockResolvedValueOnce(Response.json({ available_count: 0, credits: [] })) + .mockResolvedValueOnce(Response.json({ code: 'already_redeemed' })) + setMainHttpClient({ fetch: request, proxySession: () => null }) + const deadline = vi.spyOn(AbortSignal, 'timeout') + + await expect( + fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\.codex' }) + ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 12 } }) + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/synthetic-home', + idempotencyKey: 'synthetic-request' + }) + ).resolves.toBe('alreadyRedeemed') + + expect(request.mock.calls.map(([url]) => url)).toEqual([ + 'https://chatgpt.com/backend-api/wham/usage', + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume' + ]) + for (const [, init] of request.mock.calls) { + expect(init).toEqual( + expect.objectContaining({ + credentials: 'omit', + headers: expect.objectContaining({ + Authorization: 'Bearer synthetic-token', + 'ChatGPT-Account-Id': 'synthetic-account' + }), + signal: expect.any(AbortSignal) + }) + ) + } + expect(request).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ redeem_request_id: 'synthetic-request' }), + headers: expect.objectContaining({ 'Content-Type': 'application/json' }) + }) + ) + expect(deadline.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([ + 10_000, 10_000, 30_000 + ]) + expect(fetch).not.toHaveBeenCalled() + expect(childSpawnMock).not.toHaveBeenCalled() + }) + + it('does not fall back to global fetch when the configured host transport fails', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' } + }) + ) + const failure = new Error('fixture proxy refused the connection') + const request = vi.fn().mockRejectedValue(failure) + setMainHttpClient({ fetch: request, proxySession: () => null }) + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/synthetic-home', + idempotencyKey: 'synthetic-request' + }) + ).rejects.toBe(failure) + expect(request).toHaveBeenCalledOnce() + expect(fetch).not.toHaveBeenCalled() + }) + it('uses the official backend usage contract without spawning Codex', async () => { readFileMock.mockResolvedValue( JSON.stringify({ diff --git a/src/main/rate-limits/codex-fetcher-process-contract.test.ts b/src/main/rate-limits/codex-fetcher-process-contract.test.ts index e1449ac09bd1..7310a36f56b7 100644 --- a/src/main/rate-limits/codex-fetcher-process-contract.test.ts +++ b/src/main/rate-limits/codex-fetcher-process-contract.test.ts @@ -69,6 +69,14 @@ if (process.env.CODEX_HOME !== process.env.ORCA_EXPECTED_CODEX_HOME) { process.stderr.write('managed CODEX_HOME was not preserved\\n') process.exit(3) } +if (process.env.ORCA_EXPECTED_PROXY_ENV) { + for (const [key, value] of Object.entries(JSON.parse(process.env.ORCA_EXPECTED_PROXY_ENV))) { + if (process.env[key] !== value) { + process.stderr.write('probe proxy environment mismatch: ' + key + '\\n') + process.exit(4) + } + } +} let buffer = '' function send(message) { process.stdout.write(JSON.stringify(message) + '\\n') @@ -118,6 +126,7 @@ describe('Codex rate-limit process contract', () => { }) afterEach(() => { + vi.unstubAllEnvs() if (previousExpectedHome === undefined) { delete process.env.ORCA_EXPECTED_CODEX_HOME } else { @@ -141,4 +150,52 @@ describe('Codex rate-limit process contract', () => { }) expect(ptySpawnMock).not.toHaveBeenCalled() }) + + it.each(['localhost;*.fixture.invalid', ''])( + 'passes configured proxy and bypass %j to the actual probe', + async (bypassRules) => { + const proxy = 'http://proxy.fixture.invalid:8080' + const expected = { + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + ALL_PROXY: proxy, + http_proxy: proxy, + https_proxy: proxy, + all_proxy: proxy, + NO_PROXY: bypassRules.replaceAll(';', ','), + no_proxy: bypassRules.replaceAll(';', ',') + } + vi.stubEnv('NO_PROXY', 'inherited.invalid') + vi.stubEnv('no_proxy', 'inherited.invalid') + vi.stubEnv('ORCA_EXPECTED_PROXY_ENV', JSON.stringify(expected)) + + await expect( + fetchCodexRateLimits({ + codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME, + networkProxySettings: { httpProxyUrl: proxy, httpProxyBypassRules: bypassRules } + }) + ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 17 } }) + expect(process.env.NO_PROXY).toBe('inherited.invalid') + } + ) + + it.each([undefined, { httpProxyUrl: '' }, { httpProxyUrl: 'file:///invalid-proxy' }])( + 'preserves inherited proxy settings when app settings are unset or invalid: %j', + async (networkProxySettings) => { + const inherited = { + HTTPS_PROXY: 'http://inherited.fixture.invalid:8080', + NO_PROXY: 'fixture.invalid' + } + for (const [key, value] of Object.entries(inherited)) { + vi.stubEnv(key, value) + } + vi.stubEnv('ORCA_EXPECTED_PROXY_ENV', JSON.stringify(inherited)) + await expect( + fetchCodexRateLimits({ + codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME, + networkProxySettings + }) + ).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 17 } }) + } + ) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 3fca3d394a12..d96525a3d4e5 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -14,6 +14,8 @@ import { import { isCodexStateDbBackfillPending } from '../codex/codex-state-db' import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' import { spawnProcess } from '@orca/process-host' +import { buildConfiguredProxyEnv } from '../../shared/network-proxy' +import { getMainHttpClient } from '../network/http-client' import { probeCodexAuthPresence } from './codex-auth-presence' import { fetchCodexRateLimitsViaBackend, @@ -78,16 +80,9 @@ function processEnvWithoutCodexHome(): NodeJS.ProcessEnv { return env } -function fetchCodexUsage(url: string, init: RequestInit): Promise { - return fetch(url, init) -} - -function fetchCodexResetCredits(url: string, init: RequestInit): Promise { - return fetch(url, init) -} - -function consumeCodexResetCredit(url: string, init: RequestInit): Promise { - return fetch(url, init) +function requestCodexBackend(url: string, init: RequestInit): Promise { + // Keep account authentication explicit; the desktop session may hold unrelated cookies. + return getMainHttpClient().fetch(url, { ...init, credentials: 'omit' }) } async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise { @@ -107,6 +102,8 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise { - return consumeCodexRateLimitResetCreditFromBackend(options, consumeCodexResetCredit) + return consumeCodexRateLimitResetCreditFromBackend(options, requestCodexBackend) } async function supplementBackendMetadata( limits: ProviderRateLimits, options?: CodexRateLimitFetchOptions ): Promise { - const withSession = await supplementCodexSessionWindow(limits, fetchCodexUsage, options) - return supplementCodexRateLimitResetCredits(withSession, fetchCodexResetCredits, options) + const withSession = await supplementCodexSessionWindow(limits, requestCodexBackend, options) + return supplementCodexRateLimitResetCredits(withSession, requestCodexBackend, options) } function codexUnavailable(error: string, status: 'error' | 'unavailable'): ProviderRateLimits { @@ -150,12 +147,12 @@ async function fetchBackendUsage( options: CodexRateLimitFetchOptions | undefined ): Promise { try { - const result = await fetchCodexRateLimitsViaBackend(fetchCodexUsage, options) + const result = await fetchCodexRateLimitsViaBackend(requestCodexBackend, options) if (options?.signal?.aborted) { return abortedCodexRateLimitResult() } return result - ? supplementCodexRateLimitResetCredits(result, fetchCodexResetCredits, options) + ? supplementCodexRateLimitResetCredits(result, requestCodexBackend, options) : null } catch { return options?.signal?.aborted ? abortedCodexRateLimitResult() : null diff --git a/src/main/rate-limits/codex-rate-limit-fetch-options.ts b/src/main/rate-limits/codex-rate-limit-fetch-options.ts index a130dfe82893..c29607d86653 100644 --- a/src/main/rate-limits/codex-rate-limit-fetch-options.ts +++ b/src/main/rate-limits/codex-rate-limit-fetch-options.ts @@ -1,4 +1,7 @@ +import type { NetworkProxySettings } from '../../shared/network-proxy' + export type CodexRateLimitFetchOptions = { codexHomePath?: string | null + networkProxySettings?: NetworkProxySettings signal?: AbortSignal } diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index dba478291d7f..acab66c9f4fc 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -69,6 +69,36 @@ describe('RateLimitService', () => { resetRateLimitProviderMocks() }) + it.each([ + [ + 'account-change refresh', + (service: RateLimitService) => service.refreshForCodexAccountChange(null, { runtime: 'host' }) + ], + [ + 'target refresh', + (service: RateLimitService) => service.refreshCodexForTarget({ runtime: 'host' }) + ] + ])('passes execution-host proxy settings through %s', async (_label, refresh) => { + const service = new RateLimitService() + const networkProxySettings = { httpProxyUrl: 'http://proxy.fixture.invalid:8080' } + service.setCodexHomePathResolver(() => ({ + kind: 'ready', + codexHomePath: '/managed/synthetic-home' + })) + service.setNetworkProxySettingsResolver(() => networkProxySettings) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await refresh(service) + + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + codexHomePath: '/managed/synthetic-home', + networkProxySettings, + signal: expect.any(AbortSignal) + }) + ) + }) + it('passes the selected WSL Codex home into active account rate-limit fetches', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index ca05c7c0fb92..6ba3023fa082 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -136,6 +136,8 @@ describe('RateLimitService', () => { it('passes WSL Codex managed homes into inactive account rate-limit fetches', async () => { const service = new RateLimitService() + const networkProxySettings = { httpProxyUrl: 'http://proxy.fixture.invalid:8080' } + service.setNetworkProxySettingsResolver(() => networkProxySettings) const wslCodexHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home' service.setInactiveCodexAccountsResolver(() => [ @@ -148,6 +150,7 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledWith( expect.objectContaining({ codexHomePath: wslCodexHome, + networkProxySettings, signal: expect.any(AbortSignal) }) ) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 2cef94ba2b3e..1c089f0701c4 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -458,6 +458,9 @@ describe('RateLimitService', () => { ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ networkProxySettings, signal: expect.any(AbortSignal) }) + ) expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 3da1389d4830..7f9fb882e6d6 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -130,6 +130,7 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul try { fresh = await fetchCodexRateLimits({ codexHomePath, + networkProxySettings: this.networkProxySettingsResolver?.(), signal: controller.signal }) } catch (error) { diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index f847cbf15097..c6ad83fe6549 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -230,6 +230,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ : (missingWslCodexHome ?? fetchCodexRateLimits({ codexHomePath, + networkProxySettings: this.networkProxySettingsResolver?.(), signal })), fetchGeminiRateLimits(geminiCliOAuthEnabled), diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index 70f06c093f7f..d5d568a2a7be 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -144,6 +144,7 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. const fresh = await fetchCodexRateLimits({ codexHomePath: home.managedHomePath, + networkProxySettings: this.networkProxySettingsResolver?.(), signal }) if ( diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index fdb7040b58af..0932d6fa7a73 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -40,6 +40,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul ? Promise.resolve(missingWslCodexHome) : fetchCodexRateLimits({ codexHomePath, + networkProxySettings: this.networkProxySettingsResolver?.(), signal }) ).catch((err): ProviderRateLimits => ({ From 0024735e8272050b82ce6ec03ddb0b71bdc36702 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 00:56:48 -0700 Subject: [PATCH 23/58] fix(ssh): remove the relay-only remote workspace sync so reconnect never sticks on pulling (#27409) * fix(ssh): remove the relay-only remote workspace sync so reconnect never sticks on pulling * refactor(ssh): drop snapshot apply tokens and terminal finalization only the sync used * test: drop leftover sync-status mock --------- Co-authored-by: m4air --- config/reliability-gates.jsonc | 121 +-- config/scripts/ci-shard-timings.json | 26 - .../scripts/ssh-cleanup-tab-map-benchmark.mjs | 8 - .../scripts/vitest-sqlite-runtime-files.mjs | 1 - src/main/ipc/remote-workspace-cache.test.ts | 162 ---- .../ipc/remote-workspace-client-identity.ts | 11 - ...remote-workspace-connected-clients.test.ts | 47 -- .../ipc/remote-workspace-connected-clients.ts | 76 -- src/main/ipc/remote-workspace-events.ts | 24 - src/main/ipc/remote-workspace-namespace.ts | 12 - .../ipc/remote-workspace-patch-queue.test.ts | 775 ------------------ src/main/ipc/remote-workspace-patch-queue.ts | 32 - src/main/ipc/remote-workspace-relay-sync.ts | 139 ---- .../ipc/remote-workspace-snapshot-cache.ts | 160 ---- ...e-workspace-snapshot-normalization.test.ts | 88 -- ...remote-workspace-snapshot-normalization.ts | 106 --- .../ipc/remote-workspace-stale-resync.test.ts | 445 ---------- src/main/ipc/remote-workspace-stale-resync.ts | 97 --- ...te-workspace-target-session-export.test.ts | 54 -- .../remote-workspace-target-session-export.ts | 143 ---- src/main/ipc/remote-workspace.test.ts | 449 ---------- src/main/ipc/remote-workspace.ts | 288 ------- .../ssh-host-partition-session-export.test.ts | 238 ------ .../attach-main-window-services.test.ts | 13 - .../window/attach-main-window-services.ts | 2 - src/preload/api-types.ts | 1 - src/preload/api/remote-workspace-bridge.ts | 19 - src/preload/api/workspace-session-api.ts | 21 - src/preload/index.ts | 2 - ...space-unplaced-upload-suppression.test.tsx | 496 ----------- .../app-shell/use-app-session-persistence.ts | 156 +--- .../status-bar/SshStatusSegment.tsx | 61 +- .../status-bar/SshTargetStatusRow.tsx | 61 +- .../ssh-status-segment-copy.test.ts | 10 +- .../status-bar/ssh-status-segment-copy.ts | 12 - .../status-bar-copy-localization.test.tsx | 26 +- ...mote-workspace-target-sync-test-harness.ts | 225 ----- .../direct-ssh-reconnect-coordinator-types.ts | 7 - .../direct-ssh-reconnect-coordinator.test.ts | 30 +- .../hooks/direct-ssh-reconnect-coordinator.ts | 19 - .../src/hooks/direct-ssh-reconnect-tokens.ts | 27 +- .../hooks/direct-ssh-state-routing.test.ts | 12 +- .../src/hooks/direct-ssh-state-routing.ts | 8 +- ...vents-agent-status-window-test-fixtures.ts | 4 +- .../ipc-events-ssh-authority-test-fixtures.ts | 3 - .../src/hooks/ipc-events-test-harness.ts | 3 +- .../ipc-events/app-lifetime-ipc-bridge.ts | 2 - .../ipc-events/direct-ssh-bridge-runtime.ts | 54 +- .../direct-ssh-connect-reply-routing.test.ts | 10 +- .../direct-ssh-hydration-fanout.test.ts | 3 +- ...rect-ssh-hydration-target-metadata.test.ts | 3 +- .../ipc-events/direct-ssh-state-ipc-bridge.ts | 6 +- .../ipc-events/remote-workspace-ipc-bridge.ts | 50 -- ...workspace-deferred-placement-retry.test.ts | 154 ---- ...mote-workspace-deferred-placement-retry.ts | 130 --- ...pace-push-status-snapshot-lifetime.test.ts | 223 ----- .../src/hooks/remote-workspace-push-status.ts | 75 -- ...ace-session-merge-close-tombstones.test.ts | 170 ---- ...on-merge-closed-terminal-tombstone.test.ts | 85 -- ...pace-session-merge-local-selection.test.ts | 124 --- ...space-session-merge-local-survival.test.ts | 450 ---------- ...ce-session-merge-parked-scrollback.test.ts | 122 --- .../hooks/remote-workspace-session-merge.ts | 376 --------- .../remote-workspace-session-readiness.ts | 48 -- ...pshot-apply-deferred-session-write.test.ts | 328 -------- .../hooks/remote-workspace-snapshot-apply.ts | 277 ------- ...space-snapshot-arrival-coordinator.test.ts | 56 -- ...-workspace-snapshot-arrival-coordinator.ts | 60 -- ...pace-snapshot-duplicate-tab-repair.test.ts | 228 ------ ...-snapshot-fresh-client-tab-seeding.test.ts | 183 ----- ...kspace-snapshot-local-tab-survival.test.ts | 202 ----- .../remote-workspace-snapshot-placement.ts | 165 ---- ...ace-snapshot-unplaced-tab-adoption.test.ts | 391 --------- .../remote-workspace-target-sync-types.ts | 45 - .../remote-workspace-target-sync.test.ts | 726 ---------------- .../src/hooks/remote-workspace-target-sync.ts | 300 ------- .../hooks/ssh-reconnect-pane-retry.test.ts | 5 +- ...gent-status-connection-attribution.test.ts | 97 --- ...cEvents-agent-status-ssh-authority.test.ts | 4 - .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 - src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 8 - src/renderer/src/i18n/locales/fr.json | 8 - src/renderer/src/i18n/locales/ja.json | 8 - src/renderer/src/i18n/locales/ko.json | 8 - src/renderer/src/i18n/locales/zh.json | 8 - ...t-session-direct-ssh-hydration-gap.test.ts | 38 +- ...agent-session-execution-host-scope.test.ts | 13 +- ...ailed-target-sleeping-agent-resume.test.ts | 117 --- .../src/lib/workspace-session-host-split.ts | 6 +- ...e-session-ssh-partition-round-trip.test.ts | 110 +-- .../workspace-terminal-host-authority.test.ts | 130 +-- .../lib/workspace-terminal-host-authority.ts | 76 +- .../worktree-agent-activation-seam.test.ts | 6 +- .../store/slices/ssh-target-cleanup.test.ts | 13 - .../src/store/slices/ssh-target-cleanup.ts | 20 - src/renderer/src/store/slices/ssh.test.ts | 15 - src/renderer/src/store/slices/ssh.ts | 39 - .../terminal-pty-identity-replacement.test.ts | 71 +- src/renderer/src/store/slices/terminals.ts | 1 - .../teardown/remove-worktree-store-cleanup.ts | 1 - .../teardown/worktree-purge-state.ts | 1 - .../src/store/terminals/terminal-actions.ts | 3 +- .../store/terminals/terminal-layout-state.ts | 43 +- .../src/store/terminals/terminal-state.ts | 7 - .../terminal-tab-close-intent.test.ts | 40 - .../src/store/terminals/terminal-tab-close.ts | 1 - .../workspace-terminal-hydration-patch.ts | 7 +- ...emote-workspace-session-projection.test.ts | 450 ---------- .../remote-workspace-session-projection.ts | 267 ------ src/shared/remote-workspace-types.ts | 85 -- src/shared/workspace-session-state-types.ts | 5 +- ...ace-session-stranded-partition-adoption.ts | 3 +- .../direct-ssh-snapshot-parking.unit.test.ts | 290 ------- 114 files changed, 155 insertions(+), 11627 deletions(-) delete mode 100644 src/main/ipc/remote-workspace-cache.test.ts delete mode 100644 src/main/ipc/remote-workspace-client-identity.ts delete mode 100644 src/main/ipc/remote-workspace-connected-clients.test.ts delete mode 100644 src/main/ipc/remote-workspace-connected-clients.ts delete mode 100644 src/main/ipc/remote-workspace-events.ts delete mode 100644 src/main/ipc/remote-workspace-namespace.ts delete mode 100644 src/main/ipc/remote-workspace-patch-queue.test.ts delete mode 100644 src/main/ipc/remote-workspace-patch-queue.ts delete mode 100644 src/main/ipc/remote-workspace-relay-sync.ts delete mode 100644 src/main/ipc/remote-workspace-snapshot-cache.ts delete mode 100644 src/main/ipc/remote-workspace-snapshot-normalization.test.ts delete mode 100644 src/main/ipc/remote-workspace-snapshot-normalization.ts delete mode 100644 src/main/ipc/remote-workspace-stale-resync.test.ts delete mode 100644 src/main/ipc/remote-workspace-stale-resync.ts delete mode 100644 src/main/ipc/remote-workspace-target-session-export.test.ts delete mode 100644 src/main/ipc/remote-workspace-target-session-export.ts delete mode 100644 src/main/ipc/remote-workspace.test.ts delete mode 100644 src/main/ipc/remote-workspace.ts delete mode 100644 src/main/ipc/ssh-host-partition-session-export.test.ts delete mode 100644 src/preload/api/remote-workspace-bridge.ts delete mode 100644 src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx delete mode 100644 src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts delete mode 100644 src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-push-status.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-merge.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-session-readiness.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-apply.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-placement.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-target-sync-types.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-target-sync.test.ts delete mode 100644 src/renderer/src/hooks/remote-workspace-target-sync.ts delete mode 100644 src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts delete mode 100644 src/shared/remote-workspace-session-projection.test.ts delete mode 100644 src/shared/remote-workspace-session-projection.ts delete mode 100644 src/shared/remote-workspace-types.ts delete mode 100644 tests/e2e/direct-ssh-snapshot-parking.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c7e1d657a19f..6ec8d482367d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1603,41 +1603,29 @@ "protection": "partial", "owner": "workspace-session-persistence", "layer": "persistence-integration", - "surfaces": [ - "workspace session partitions", - "direct SSH remote workspace sync", - "boot session hydration" - ], + "surfaces": ["workspace session partitions", "boot session hydration"], "platforms": ["macos", "linux", "windows"], "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Drives the real Store against a temp profile, the real remoteWorkspace:setForConnectedTargets handler, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", + "coverageNotes": "Drives the real boot read and the shipping host split against partitioned session fixtures; no live SSH host is exercised, and the partition routing under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/12721", "https://github.com/stablyai/orca/issues/18173", "https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts" ], - "invariant": "Every workspace the `ssh:` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:`, which is the repair's own input.", - "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", + "invariant": "Every workspace the `ssh:` partition names is hydrated at boot, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:`, which is the repair's own input.", + "oracle": "Assert the renderer routes an SSH folder workspace to `ssh:`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Assert the reunited workspace routes to `ssh:`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts" ], "testFiles": [ - "src/main/ipc/ssh-host-partition-session-export.test.ts", "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts", "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts", "src/renderer/src/lib/workspace-session-host-contention.test.ts", "src/shared/workspace-session-partition-owner.test.ts" ], "assertionRefs": [ - { - "file": "src/main/ipc/ssh-host-partition-session-export.test.ts", - "assertions": [ - "publishes tabs the runtime persisted into the target ssh partition", - "never replaces the host snapshot with an empty list for a worktree that has tabs" - ] - }, { "file": "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts", "assertions": [ @@ -1696,7 +1684,7 @@ "date": "2026-09-15", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts", "result": "passed", "durationSeconds": 7.13, "summary": "64 tests passed across 5 files: the real Store publish, the shipping write/read split round trip, the older-client empty-publish skew direction, the contested-bare-id and empty-host-row guards, the closed-last-terminal tombstone boundary, and the partition-ownership suite covering folder-workspace routing, the persistence-side partition census, read-source write-back, and the catalog attribution that keeps a residue partition from winning the read." @@ -13139,7 +13127,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "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, superseded readiness and placement waits release immediately, delayed worktree placement keeps only the latest arrival's waiter per target, stale arrivals cannot hydrate or publish push status, an incoming snapshot revokes replace-session upload authority before preparation yields, already-captured uploads revalidate that exact authority after local persistence settles, and main rejects their applied revision when a newer host snapshot arrives before IPC admission or while queued. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. 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-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. 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.", + "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. A mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. 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-transport recovery clears every error that transport surfaced without clearing or displaying a sibling pane's errors, while transport suppression, pane retention, and the rendered surface are independently bounded. 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://linear.app/stably/issue/STA-3107", "https://github.com/stablyai/orca/pull/12664", @@ -13153,20 +13141,16 @@ "https://github.com/stablyai/orca/issues/12685", "https://github.com/stablyai/orca/issues/12902" ], - "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. Snapshot arrival work may leave at most one readiness or placement timer and one placement subscription live per target; a newer arrival or sync stop must release the previous wait immediately, no superseded snapshot may hydrate after its cancellation, a superseded revision-zero upload may not publish sync status, an incoming snapshot target must be upload-ineligible before snapshot preparation yields, and every upload captured earlier must revalidate the same target authority after its local write and before result publication. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. 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. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. 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. Successful current-transport recovery must clear every stale error surfaced for that pane without clearing or displaying a sibling pane's error; a later genuine failure must remain visible. Each transport retains at most eight suppressed messages, each pane retains at most eight distinct messages, and the rendered surface retains at most 24 lines and 4,000 characters. 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. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.", - "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. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. 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. A 32-arrival unplaced-snapshot burst requires exactly one live placement listener and timer throughout, then applies only revision 51 after the catalog arrives and releases both resources; a second 32-arrival burst requires exactly one readiness timer and releases it on stop, while a deferred revision-zero upload cannot publish after a newer snapshot arrives. The real persistence subscriber must also exclude a previously hydrated target from replace-session uploads while incoming snapshot capture is pending, including when the upload captured that target before its local disk write stalled. Stopping with an active placement wait releases it immediately and hydrates nothing. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.", + "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, including the authoritative incarnation returned by a successful session-ID reattach. 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. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. 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. Successful current-transport recovery must clear every stale error surfaced for that pane without clearing or displaying a sibling pane's error; a later genuine failure must remain visible. Each transport retains at most eight suppressed messages, each pane retains at most eight distinct messages, and the rendered surface retains at most 24 lines and 4,000 characters. 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. Reconnect must alternate exact activation with authoritative inventory so neither a stale activation response nor an activation failure can strand or retire a pane, and activating a parked surface whose persisted binding was already retired must respawn it rather than report a changed owner after signalling its exit.", + "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. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. 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. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", "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-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.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-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.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/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.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/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot", + "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/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.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-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.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 --reporter=dot", "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", @@ -13216,12 +13200,6 @@ "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/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts", - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", - "src/main/ipc/remote-workspace-cache.test.ts", - "src/main/ipc/remote-workspace.test.ts", - "src/main/ipc/remote-workspace-patch-queue.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", @@ -13529,54 +13507,6 @@ "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", - "placement and readiness bursts retain one latest-only listener or timer per target, apply only the newest arrival, and release immediately on stop", - "a superseded revision-zero push cannot publish stale status" - ] - }, - { - "file": "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts", - "assertions": [ - "completed target generations are released across repeated target churn", - "a superseded operation that ignores abort cannot become current through generation reuse after a newer operation completes" - ] - }, - { - "file": "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx", - "assertions": [ - "an unsolicited snapshot synchronously revokes its target's upload authority before preparation awaits", - "a session write while snapshot capture is pending cannot replace the host's newly cached tabs", - "an upload captured before snapshot arrival is revalidated after its pending local write and cannot overwrite the incoming revision", - "same-lineage local writes remain uploadable when an earlier overlapping result advances the renderer's acknowledged revision", - "a transient unavailable result retains its observed host token and revision so the next local edit retries" - ] - }, - { - "file": "src/main/ipc/remote-workspace-cache.test.ts", - "assertions": [ - "contiguous same-client patch revisions retain queued renderer bases until a host observation replaces the lineage", - "snapshot eviction removes its upload-revision authority with the same bounded cache entry" - ] - }, - { - "file": "src/main/ipc/remote-workspace.test.ts", - "assertions": [ - "replace-session upload admission requires an explicit applied revision for every hydrated target" - ] - }, - { - "file": "src/main/ipc/remote-workspace-patch-queue.test.ts", - "assertions": [ - "token A is rejected without a patch when a different same-revision host observation arrives before admission or while the upload waits in the same-target queue", - "an evicted token A fails closed after a same-revision refetch stamps a new observation token", - "same-client notification-before-response ordering preserves token A and overlapping queued writes at relay bases 7 then 8" - ] - }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts", "assertions": [ @@ -13610,33 +13540,6 @@ } ], "evidenceRuns": [ - { - "date": "2026-08-29", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts --reporter=dot", - "result": "passed", - "durationSeconds": 0.1, - "summary": "Two coordinator tests passed, proving completed target entries are released under repeated churn and generations remain monotonic until every superseded operation settles, preventing ABA re-admission." - }, - { - "date": "2026-08-29", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/remote-workspace-cache.test.ts src/main/ipc/remote-workspace.test.ts src/main/ipc/remote-workspace-patch-queue.test.ts --reporter=dot", - "result": "passed", - "durationSeconds": 4.04, - "summary": "Three main-process remote-workspace files and 17 tests passed, including token-A rejection after same-revision observations before admission and while queued, eviction/refetch fail-closed behavior, same-client overlap at bases 7 then 8, revision-zero compatibility, reset-relay fallback, and bounded cache lineage." - }, - { - "date": "2026-08-29", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx --reporter=dot", - "result": "passed", - "durationSeconds": 10.44, - "summary": "Three renderer remote-workspace files and 28 tests passed, including acknowledged observation-token propagation, incoming-lineage upload cancellation, same-lineage overlapping upload continuation, transient-unavailable retry authority, and latest-only snapshot arrival fencing." - }, { "date": "2026-08-29", "runner": "local", @@ -13650,7 +13553,7 @@ "date": "2026-08-29", "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/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "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/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.7, "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." @@ -13686,7 +13589,7 @@ "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/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "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/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.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." diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index faae98eb8003..d583f8e553f0 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -2661,13 +2661,6 @@ "src/main/ipc/registered-worktree-root-wsl-runtime.test.ts": 171, "src/main/ipc/registered-worktree-roots-scoped-invalidation.test.ts": 89, "src/main/ipc/remote-watcher-event-batch.test.ts": 77, - "src/main/ipc/remote-workspace-cache.test.ts": 5428, - "src/main/ipc/remote-workspace-connected-clients.test.ts": 51, - "src/main/ipc/remote-workspace-patch-queue.test.ts": 595, - "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 47, - "src/main/ipc/remote-workspace-stale-resync.test.ts": 1369, - "src/main/ipc/remote-workspace-target-session-export.test.ts": 254, - "src/main/ipc/remote-workspace.test.ts": 307, "src/main/ipc/renderer-lifetime-abort.test.ts": 58, "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 457, "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 56, @@ -2749,7 +2742,6 @@ "src/main/ipc/ssh-connect-recheck-cancellation.test.ts": 4164, "src/main/ipc/ssh-disconnect-cancellation.test.ts": 5166, "src/main/ipc/ssh-handler-reregistration.test.ts": 4043, - "src/main/ipc/ssh-host-partition-session-export.test.ts": 1644, "src/main/ipc/ssh-host-server-decision-failure.test.ts": 385, "src/main/ipc/ssh-host-server-on-connect-wiring.test.ts": 2374, "src/main/ipc/ssh-host-sleep-reconnect.test.ts": 424, @@ -5976,7 +5968,6 @@ "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 455, "src/renderer/src/app-shell/app-window-chrome.test.ts": 37, "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 40, - "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 2213, "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 62, "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 171, "src/renderer/src/app-shell/startup-actions-selector.test.ts": 78, @@ -9161,20 +9152,6 @@ "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2706, "src/renderer/src/hooks/modal-return-focus-action.test.ts": 53, "src/renderer/src/hooks/programmatic-scroll-marks.test.ts": 46, - "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 375, - "src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts": 2187, - "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 274, - "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 1966, - "src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts": 205, - "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 277, - "src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts": 311, - "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 1919, - "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 51, - "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 1375, - "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 1657, - "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 1560, - "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 1826, - "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 394, "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts": 70, "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 114, "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 99, @@ -9765,7 +9742,6 @@ "src/renderer/src/lib/source-control-launch-platform.test.ts": 58, "src/renderer/src/lib/source-control-remote-error.test.ts": 62, "src/renderer/src/lib/sparse-preset-draft.test.ts": 168, - "src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts": 1527, "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 1556, "src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts": 47, "src/renderer/src/lib/startup-draft-input-kind.test.ts": 464, @@ -11394,7 +11370,6 @@ "src/shared/remote-runtime-subscription-request.test.ts": 699, "src/shared/remote-runtime-tailscale-hint.test.ts": 59, "src/shared/remote-runtime-transport-error-agreement.test.ts": 219, - "src/shared/remote-workspace-session-projection.test.ts": 346, "src/shared/renderer-restart-preparation.test.ts": 169, "src/shared/repo-badge-color.test.ts": 310, "src/shared/repo-icon.test.ts": 80, @@ -11772,7 +11747,6 @@ "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 14396, "tests/e2e/cursor-quota-transport.unit.test.ts": 1102, "tests/e2e/daemon-running-work-probe.unit.test.ts": 315, - "tests/e2e/direct-ssh-snapshot-parking.unit.test.ts": 2079, "tests/e2e/folder-upgrade-identity-persistence.unit.test.ts": 2238, "tests/e2e/github-work-item-search-burst.unit.test.ts": 1180, "tests/e2e/global-setup-cli-artifact.unit.test.ts": 55, diff --git a/config/scripts/ssh-cleanup-tab-map-benchmark.mjs b/config/scripts/ssh-cleanup-tab-map-benchmark.mjs index 8ddaa783dec3..cd1496c84818 100644 --- a/config/scripts/ssh-cleanup-tab-map-benchmark.mjs +++ b/config/scripts/ssh-cleanup-tab-map-benchmark.mjs @@ -74,8 +74,6 @@ function emptyState() { sshConnectionStates: new Map(), sshTargetLabels: new Map(), sshTargetGenerations: new Map(), - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: {}, portForwardsByConnection: {}, detectedPortsByConnection: {}, sshCredentialQueue: [] @@ -170,18 +168,12 @@ for (let iteration = 0; iteration < 3000; iteration++) { if (random(2)) { state.sshTargetGenerations.set(targetId, random(10)) } - if (random(2)) { - state.remoteWorkspaceHydratedTargetIds.add(targetId) - } if (random(2)) { state.deferredSshReconnectTargets.push(targetId) } if (random(2)) { state.transientClearedAgentStatusConnectionIds[targetId] = true } - if (random(2)) { - state.remoteWorkspaceSyncStatusByTargetId[targetId] = { phase: 'synced' } - } if (random(2)) { state.portForwardsByConnection[targetId] = [{ localPort: 8000 }] } diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index ae5cc3639858..ea7a79a4394b 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -56,7 +56,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-structured-question-order.test.ts', 'src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts', 'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts', - 'src/main/ipc/ssh-host-partition-session-export.test.ts', 'src/main/ipc/ssh-host-server-on-connect-wiring.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', diff --git a/src/main/ipc/remote-workspace-cache.test.ts b/src/main/ipc/remote-workspace-cache.test.ts deleted file mode 100644 index 09bb4dfbb800..000000000000 --- a/src/main/ipc/remote-workspace-cache.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import type { - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' -import { - _getRemoteWorkspaceCacheSizesForTests, - _resetRemoteWorkspaceCachesForTests -} from './remote-workspace' -import { - REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES, - _getRemoteWorkspaceSnapshotForTests, - _rememberRemoteWorkspaceSnapshotForTests, - cachedRemoteWorkspaceSnapshotAuthorizesRevision, - rememberLocallyPatchedRemoteWorkspaceSnapshot, - rememberRemoteWorkspaceSnapshot -} from './remote-workspace-snapshot-cache' - -function emptyRemoteWorkspaceSession(): RemoteWorkspaceSession { - return { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - } -} - -function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { - return { - namespace: 'target', - revision, - updatedAt: 123, - schemaVersion: 1, - session - } -} - -describe('remote workspace snapshot cache', () => { - beforeEach(() => { - _resetRemoteWorkspaceCachesForTests() - }) - - it('LRU-evicts old target snapshots', () => { - for (let i = 0; i <= REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { - _rememberRemoteWorkspaceSnapshotForTests( - `target-${i}`, - snapshot({ - activeWorktreePath: `/repo-${i}`, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) - ) - } - - expect(_getRemoteWorkspaceCacheSizesForTests().snapshots).toBe( - REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES - ) - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeUndefined() - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-0', 7)).toBe(false) - expect(_getRemoteWorkspaceSnapshotForTests('target-1')?.session.activeWorktreePath).toBe( - '/repo-1' - ) - }) - - it('refreshes snapshot recency on cache reads', () => { - for (let i = 0; i < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; i++) { - _rememberRemoteWorkspaceSnapshotForTests( - `target-${i}`, - snapshot(emptyRemoteWorkspaceSession()) - ) - } - - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() - _rememberRemoteWorkspaceSnapshotForTests('target-new', snapshot(emptyRemoteWorkspaceSession())) - - expect(_getRemoteWorkspaceSnapshotForTests('target-0')).toBeDefined() - expect(_getRemoteWorkspaceSnapshotForTests('target-1')).toBeUndefined() - }) - - it('keeps contiguous local patch bases authorized until the host changes', () => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(emptyRemoteWorkspaceSession(), 7)) - rememberLocallyPatchedRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 8) - ) - rememberLocallyPatchedRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 9) - ) - - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 7)).toBe(true) - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 8)).toBe(true) - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 9)).toBe(true) - - rememberRemoteWorkspaceSnapshot('target-1', snapshot(emptyRemoteWorkspaceSession(), 10)) - - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 9)).toBe(false) - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 10)).toBe(true) - }) - - it('keeps the observation token stable when an unchanged revision is re-read', () => { - const first = rememberRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 7) - ) - const second = rememberRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 7) - ) - - expect(second.hostObservationToken).toBe(first.hostObservationToken) - expect(cachedRemoteWorkspaceSnapshotAuthorizesRevision('target-1', 7)).toBe(true) - }) - - it('rotates the observation token when same-revision content changes', () => { - const first = rememberRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 7) - ) - const second = rememberRemoteWorkspaceSnapshot( - 'target-1', - snapshot( - { - ...emptyRemoteWorkspaceSession(), - activeTabId: 'changed' - }, - 7 - ) - ) - - expect(second.hostObservationToken).not.toBe(first.hostObservationToken) - }) - - it('keeps local patch authority across equivalent normalized relay reads', () => { - const base = rememberRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 7) - ) - const locallyPatched = rememberLocallyPatchedRemoteWorkspaceSnapshot( - 'target-1', - snapshot( - { - ...emptyRemoteWorkspaceSession(), - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: {}, - remoteSessionIdsByTabId: {}, - lastVisitedAtByWorktreePath: {}, - defaultTerminalTabsAppliedByWorktreePath: {} - }, - 8 - ) - ) - const relayRead = rememberRemoteWorkspaceSnapshot( - 'target-1', - snapshot(emptyRemoteWorkspaceSession(), 8) - ) - - expect(locallyPatched.hostObservationToken).toBe(base.hostObservationToken) - expect(relayRead.hostObservationToken).toBe(locallyPatched.hostObservationToken) - }) -}) diff --git a/src/main/ipc/remote-workspace-client-identity.ts b/src/main/ipc/remote-workspace-client-identity.ts deleted file mode 100644 index 05a9429132f1..000000000000 --- a/src/main/ipc/remote-workspace-client-identity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' - -export type RemoteWorkspaceClientNameSource = Pick - -export const CLIENT_ID = randomUUID() - -/** Read at send time from the runtime's name, so a rename reaches the next presence frame, not the next launch. */ -export function readClientName(source: RemoteWorkspaceClientNameSource): string { - return source.readMachineName() || 'This device' -} diff --git a/src/main/ipc/remote-workspace-connected-clients.test.ts b/src/main/ipc/remote-workspace-connected-clients.test.ts deleted file mode 100644 index cfb70c6fb0df..000000000000 --- a/src/main/ipc/remote-workspace-connected-clients.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { getActiveMultiplexerMock, getSshConnectionStoreMock } = vi.hoisted(() => ({ - getActiveMultiplexerMock: vi.fn(), - getSshConnectionStoreMock: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, - getSshConnectionStore: getSshConnectionStoreMock -})) - -import { listRemoteWorkspaceConnectedClients } from './remote-workspace-connected-clients' -import { CLIENT_ID } from './remote-workspace-client-identity' - -const TARGET = { id: 'ssh-1', label: 'build box' } - -describe('listRemoteWorkspaceConnectedClients', () => { - const request = vi.fn(async () => ({ clients: [] })) - - beforeEach(() => { - request.mockClear() - getSshConnectionStoreMock.mockReturnValue({ listTargets: () => [TARGET] }) - getActiveMultiplexerMock.mockImplementation((id: string) => - id === TARGET.id ? { request } : undefined - ) - }) - - it('introduces this desktop by the name the runtime publishes, and follows a rename', async () => { - let machineName = 'Build server' - const runtime = { readMachineName: () => machineName } - - await listRemoteWorkspaceConnectedClients(undefined, runtime) - expect(request).toHaveBeenCalledWith( - 'workspace.presence', - expect.objectContaining({ clientId: CLIENT_ID, clientName: 'Build server' }) - ) - - // Why: the name is read per presence frame, so a rename in Settings reaches the host without a relaunch. - machineName = 'Renamed desk' - await listRemoteWorkspaceConnectedClients(undefined, runtime) - expect(request).toHaveBeenLastCalledWith( - 'workspace.presence', - expect.objectContaining({ clientId: CLIENT_ID, clientName: 'Renamed desk' }) - ) - }) -}) diff --git a/src/main/ipc/remote-workspace-connected-clients.ts b/src/main/ipc/remote-workspace-connected-clients.ts deleted file mode 100644 index 38701763f7ac..000000000000 --- a/src/main/ipc/remote-workspace-connected-clients.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { RemoteWorkspaceConnectedClient } from '../../shared/remote-workspace-types' -import { getActiveMultiplexer, getSshConnectionStore } from './ssh' -import { - CLIENT_ID, - readClientName, - type RemoteWorkspaceClientNameSource -} from './remote-workspace-client-identity' -import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' - -function normalizeConnectedClients( - raw: unknown, - currentClientId: string -): RemoteWorkspaceConnectedClient[] { - const clients = (raw as { clients?: unknown } | null)?.clients - if (!Array.isArray(clients)) { - return [] - } - return clients - .map((entry): RemoteWorkspaceConnectedClient | null => { - const item = entry as Partial | null - const clientId = typeof item?.clientId === 'string' ? item.clientId.trim() : '' - if (!clientId || clientId.length > 200) { - return null - } - return { - clientId, - name: - typeof item?.name === 'string' && item.name.trim() - ? item.name.replace(/\s+/g, ' ').trim().slice(0, 80) - : 'Unknown device', - lastSeenAt: - typeof item?.lastSeenAt === 'number' && Number.isFinite(item.lastSeenAt) - ? item.lastSeenAt - : 0, - isCurrent: clientId === currentClientId - } - }) - .filter((entry): entry is RemoteWorkspaceConnectedClient => entry !== null) -} - -export async function listRemoteWorkspaceConnectedClients( - args: { targetIds?: string[] } | undefined, - clientNameSource: RemoteWorkspaceClientNameSource -): Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> { - const requestedTargetIds = Array.isArray(args?.targetIds) ? new Set(args.targetIds) : null - const targets = - getSshConnectionStore() - ?.listTargets() - .filter( - (target) => - getActiveMultiplexer(target.id) && - (!requestedTargetIds || requestedTargetIds.has(target.id)) - ) ?? [] - const results: { targetId: string; clients: RemoteWorkspaceConnectedClient[] }[] = [] - for (const target of targets) { - const mux = getActiveMultiplexer(target.id) - if (!mux) { - continue - } - const namespace = getRemoteWorkspaceNamespace(target) - try { - const raw = await mux.request('workspace.presence', { - namespace, - clientId: CLIENT_ID, - clientName: readClientName(clientNameSource) - }) - results.push({ - targetId: target.id, - clients: normalizeConnectedClients(raw, CLIENT_ID) - }) - } catch { - results.push({ targetId: target.id, clients: [] }) - } - } - return results -} diff --git a/src/main/ipc/remote-workspace-events.ts b/src/main/ipc/remote-workspace-events.ts deleted file mode 100644 index cd7ae2b7c459..000000000000 --- a/src/main/ipc/remote-workspace-events.ts +++ /dev/null @@ -1,24 +0,0 @@ -type RemoteWorkspaceNotificationHandler = ( - targetId: string, - method: string, - params: Record -) => void - -const handlers = new Set() - -export function registerRemoteWorkspaceNotificationHandler( - handler: RemoteWorkspaceNotificationHandler -): () => void { - handlers.add(handler) - return () => handlers.delete(handler) -} - -export function notifyRemoteWorkspaceHandlers( - targetId: string, - method: string, - params: Record -): void { - for (const handler of handlers) { - handler(targetId, method, params) - } -} diff --git a/src/main/ipc/remote-workspace-namespace.ts b/src/main/ipc/remote-workspace-namespace.ts deleted file mode 100644 index 100924e49c67..000000000000 --- a/src/main/ipc/remote-workspace-namespace.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createHash } from 'node:crypto' -import type { SshTarget } from '../../shared/ssh-types' - -export function getRemoteWorkspaceNamespace(target: SshTarget): string { - const stableKey = [ - target.configHost || target.host, - target.host, - String(target.port), - target.username - ].join('\n') - return createHash('sha256').update(stableKey).digest('hex').slice(0, 32) -} diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts deleted file mode 100644 index d82ba5e845a5..000000000000 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ /dev/null @@ -1,775 +0,0 @@ -import { ipcMain } from 'electron' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type { - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -const { - getActiveMultiplexerMock, - getSshConnectionStoreMock, - registerRemoteWorkspaceNotificationHandlerMock -} = vi.hoisted(() => ({ - getActiveMultiplexerMock: vi.fn(), - getSshConnectionStoreMock: vi.fn(), - registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) -})) - -vi.mock('electron', () => ({ - ipcMain: { - handle: vi.fn(), - removeHandler: vi.fn() - } -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, - getSshConnectionStore: getSshConnectionStoreMock -})) - -vi.mock('./remote-workspace-events', () => ({ - registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock -})) - -import { - _resetRemoteWorkspaceCachesForTests, - handleRemoteWorkspaceNotification, - registerRemoteWorkspaceHandlers -} from './remote-workspace' -import { CLIENT_ID } from './remote-workspace-client-identity' -import { queueRemoteWorkspacePatch } from './remote-workspace-patch-queue' -import { - REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES, - getCachedRemoteWorkspaceSnapshot, - rememberRemoteWorkspaceSnapshot -} from './remote-workspace-snapshot-cache' - -function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { - return { - namespace: 'target', - revision, - updatedAt: 123, - schemaVersion: 1, - session - } -} - -function sessionWithTab(worktreeId: string, tabId: string): WorkspaceSessionState { - return { - activeRepoId: null, - activeWorktreeId: worktreeId, - activeTabId: tabId, - tabsByWorktree: { - [worktreeId]: [{ id: tabId, type: 'terminal', title: 'Shell', worktreeId } as never] - }, - terminalLayoutsByTabId: {} - } -} - -function patchSession(params: Record): RemoteWorkspaceSession { - return (params.patch as { session: RemoteWorkspaceSession }).session -} - -describe('remoteWorkspace:setForConnectedTargets patch queue', () => { - const handlers = new Map unknown>() - const muxByTargetId = new Map }>() - const getRepoMock = vi.fn() - // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. - const KNOWN_REPO_IDS = ['repo-target-1', 'repo-target-2', 'repo-reset', 'repo-newer'] - const store = { - getRepo: getRepoMock, - getRepos: () => KNOWN_REPO_IDS.map((repoId) => getRepoMock(repoId)).filter(Boolean) - } as unknown as Store - - const target: SshTarget = { - id: 'target-1', - label: 'Target 1', - host: 'one.example.com', - port: 22, - username: 'alice' - } - - beforeEach(() => { - _resetRemoteWorkspaceCachesForTests() - handlers.clear() - muxByTargetId.clear() - vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) - vi.mocked(ipcMain.removeHandler).mockReset() - getSshConnectionStoreMock.mockReset() - getSshConnectionStoreMock.mockReturnValue({ - listTargets: () => [target], - getTarget: (targetId: string) => (targetId === target.id ? target : undefined) - }) - getRepoMock.mockReset() - getRepoMock.mockImplementation((repoId: string) => - repoId === 'repo-target-1' - ? ({ - id: 'repo-target-1', - path: '/remote/repo', - displayName: 'Repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'target-1' - } as never) - : undefined - ) - getActiveMultiplexerMock.mockReset() - getActiveMultiplexerMock.mockImplementation((targetId: string) => muxByTargetId.get(targetId)) - registerRemoteWorkspaceNotificationHandlerMock.mockClear() - - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) - }) - - async function callSetForConnectedTargets(args: { - session: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - - function observeSnapshot(targetId: string, value: RemoteWorkspaceSnapshot): string { - return rememberRemoteWorkspaceSnapshot(targetId, value).hostObservationToken - } - - function cachedObservationToken(targetId: string): string { - const cached = getCachedRemoteWorkspaceSnapshot(targetId) - if (!cached) { - throw new Error(`No cached workspace observation for ${targetId}`) - } - return cached.hostObservationToken - } - - it('serializes overlapping writes for the same target so they use fresh base revisions', async () => { - let currentRevision = 7 - let releaseFirstPatch!: () => void - const firstPatchCanFinish = new Promise((resolve) => { - releaseFirstPatch = resolve - }) - const patchBaseRevisions: number[] = [] - const request = vi - .fn() - .mockImplementation(async (method: string, params: Record) => { - if (method === 'workspace.get') { - return snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - currentRevision - ) - } - if (method === 'workspace.patch') { - patchBaseRevisions.push(params.baseRevision as number) - if (patchBaseRevisions.length === 1) { - await firstPatchCanFinish - } - currentRevision += 1 - const patchedSnapshot = snapshot(patchSession(params), currentRevision) - handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { - snapshot: patchedSnapshot, - sourceClientId: CLIENT_ID - }) - return { - ok: true, - snapshot: patchedSnapshot - } - } - throw new Error(`Unexpected method ${method}`) - }) - muxByTargetId.set('target-1', { request }) - const observationToken = observeSnapshot( - 'target-1', - snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - ) - - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - await vi.waitFor(() => expect(patchBaseRevisions).toEqual([7])) - - const second = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace-b', 'tab-b'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(patchBaseRevisions).toEqual([7]) - - releaseFirstPatch() - await expect(Promise.all([first, second])).resolves.toMatchObject([ - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 8, hostObservationToken: observationToken } - } - } - ], - [ - { - targetId: 'target-1', - result: { - ok: true, - snapshot: { revision: 9, hostObservationToken: observationToken } - } - } - ] - ]) - expect(patchBaseRevisions).toEqual([7, 8]) - }) - - it('rejects token A after a different same-revision host observation arrives before admission', async () => { - const remoteSnapshot = snapshot( - { - activeWorktreePath: '/other-device', - activeTabId: 'host-tab', - tabsByWorktreePath: { - '/other-device': [{ id: 'host-tab', worktreePath: '/other-device' } as never] - }, - terminalLayoutsByTabId: {} - }, - 7 - ) - const request = vi.fn() - muxByTargetId.set('target-1', { request }) - const observationToken = observeSnapshot( - 'target-1', - snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - ) - - handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { - snapshot: remoteSnapshot, - sourceClientId: 'other-client' - }) - const result = await callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - - expect(result).toMatchObject([ - { - targetId: 'target-1', - result: { - ok: false, - reason: 'stale-revision', - snapshot: { revision: 7 } - } - } - ]) - expect(request).not.toHaveBeenCalled() - }) - - it('rejects a renderer upload when a host snapshot arrives while it is queued', async () => { - const remoteSnapshot = snapshot( - { - activeWorktreePath: '/other-device', - activeTabId: 'host-tab', - tabsByWorktreePath: { - '/other-device': [{ id: 'host-tab', worktreePath: '/other-device' } as never] - }, - terminalLayoutsByTabId: {} - }, - 8 - ) - let releasePatch!: () => void - const patchCanFinish = new Promise((resolve) => { - releasePatch = resolve - }) - const request = vi.fn(async (method: string) => { - if (method === 'workspace.get') { - return snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - } - if (method === 'workspace.patch') { - await patchCanFinish - return { ok: false, reason: 'stale-revision', snapshot: remoteSnapshot } - } - throw new Error(`Unexpected method ${method}`) - }) - muxByTargetId.set('target-1', { request }) - const observationToken = observeSnapshot( - 'target-1', - snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - ) - - const first = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/first', 'first-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - await vi.waitFor(() => - expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) - ) - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/queued', 'queued-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - await new Promise((resolve) => setTimeout(resolve, 0)) - - handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { - snapshot: remoteSnapshot, - sourceClientId: 'other-client' - }) - releasePatch() - - await expect(Promise.all([first, queued])).resolves.toMatchObject([ - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }], - [{ targetId: 'target-1', result: { ok: false, reason: 'stale-revision' } }] - ]) - expect(request.mock.calls.filter(([method]) => method === 'workspace.patch')).toHaveLength(1) - }) - - it('rejects a queued upload after a same-revision host observation replaces its lineage', async () => { - const baseline = snapshot( - { - activeWorktreePath: '/baseline', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - const replacement = snapshot( - { - activeWorktreePath: '/other-device', - activeTabId: 'host-tab', - tabsByWorktreePath: { - '/other-device': [{ id: 'host-tab', worktreePath: '/other-device' } as never] - }, - terminalLayoutsByTabId: {} - }, - 7 - ) - const request = vi.fn(async (method: string, params: Record) => { - if (method !== 'workspace.patch') { - throw new Error(`Unexpected method ${method}`) - } - return { ok: true, snapshot: snapshot(patchSession(params), 8) } - }) - muxByTargetId.set('target-1', { request }) - handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { - snapshot: baseline, - sourceClientId: CLIENT_ID - }) - const observationToken = cachedObservationToken('target-1') - - let releaseBlocker!: () => void - const blockerCanFinish = new Promise((resolve) => { - releaseBlocker = resolve - }) - let blockerStarted!: () => void - const blockerDidStart = new Promise((resolve) => { - blockerStarted = resolve - }) - const blocker = queueRemoteWorkspacePatch('target-1', async () => { - blockerStarted() - await blockerCanFinish - }) - await blockerDidStart - - const queued = callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - await new Promise((resolve) => setTimeout(resolve, 0)) - handleRemoteWorkspaceNotification('target-1', 'workspace.changed', { - snapshot: replacement, - sourceClientId: 'other-client' - }) - releaseBlocker() - await blocker - - await expect(queued).resolves.toMatchObject([ - { - targetId: 'target-1', - result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } } - } - ]) - expect(request).not.toHaveBeenCalled() - }) - - it('fails closed after token A is evicted even when the fetched revision still matches', async () => { - const baseline = snapshot( - { - activeWorktreePath: '/baseline', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - const observationToken = observeSnapshot('target-1', baseline) - for (let index = 0; index < REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES; index += 1) { - observeSnapshot(`eviction-target-${index}`, baseline) - } - expect(getCachedRemoteWorkspaceSnapshot('target-1')).toBeUndefined() - - const request = vi.fn(async (method: string) => { - if (method === 'workspace.get') { - return baseline - } - throw new Error(`Unexpected method ${method}`) - }) - muxByTargetId.set('target-1', { request }) - - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-target-1::/remote/workspace', 'stale-local-tab'), - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': observationToken } - }) - ).resolves.toMatchObject([ - { - targetId: 'target-1', - result: { ok: false, reason: 'stale-revision', snapshot: { revision: 7 } } - } - ]) - expect(request.mock.calls.map(([method]) => method)).toEqual(['workspace.get']) - }) - - it('patches independent hydrated targets concurrently', async () => { - const secondTarget: SshTarget = { - id: 'target-2', - label: 'Target 2', - host: 'two.example.com', - port: 22, - username: 'alice' - } - getSshConnectionStoreMock.mockReturnValue({ - listTargets: () => [target, secondTarget] - }) - getRepoMock.mockImplementation((repoId: string) => { - if (repoId === 'repo-target-1') { - return { - id: 'repo-target-1', - path: '/remote/repo-a', - displayName: 'Repo A', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'target-1' - } as never - } - if (repoId === 'repo-target-2') { - return { - id: 'repo-target-2', - path: '/remote/repo-b', - displayName: 'Repo B', - badgeColor: 'green', - addedAt: 1, - connectionId: 'target-2' - } as never - } - return undefined - }) - - let releaseFirstPatch!: () => void - const firstPatchCanFinish = new Promise((resolve) => { - releaseFirstPatch = resolve - }) - const previousSnapshot = snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - const slowRequest = vi.fn(async (method: string, params: Record) => { - if (method === 'workspace.get') { - return previousSnapshot - } - if (method === 'workspace.patch') { - await firstPatchCanFinish - return { ok: true, snapshot: snapshot(patchSession(params), 8) } - } - throw new Error(`Unexpected method ${method}`) - }) - const fastRequest = vi.fn(async (method: string, params: Record) => { - if (method === 'workspace.get') { - return previousSnapshot - } - if (method === 'workspace.patch') { - return { ok: true, snapshot: snapshot(patchSession(params), 8) } - } - throw new Error(`Unexpected method ${method}`) - }) - muxByTargetId.set('target-1', { request: slowRequest }) - muxByTargetId.set('target-2', { request: fastRequest }) - const firstObservationToken = observeSnapshot('target-1', previousSnapshot) - const secondObservationToken = observeSnapshot('target-2', previousSnapshot) - - const resultPromise = callSetForConnectedTargets({ - session: { - ...sessionWithTab('repo-target-1::/remote/workspace-a', 'tab-a'), - tabsByWorktree: { - 'repo-target-1::/remote/workspace-a': [ - { - id: 'tab-a', - type: 'terminal', - title: 'Shell A', - worktreeId: 'repo-target-1::/remote/workspace-a' - } as never - ], - 'repo-target-2::/remote/workspace-b': [ - { - id: 'tab-b', - type: 'terminal', - title: 'Shell B', - worktreeId: 'repo-target-2::/remote/workspace-b' - } as never - ] - } - }, - hydratedTargetIds: ['target-1', 'target-2'], - expectedRevisionsByTargetId: { 'target-1': 7, 'target-2': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': firstObservationToken, - 'target-2': secondObservationToken - } - }) - - await vi.waitFor(() => - expect(slowRequest.mock.calls.some(([method]) => method === 'workspace.patch')).toBe(true) - ) - await vi.waitFor(() => - expect(fastRequest.mock.calls.some(([method]) => method === 'workspace.patch')).toBe(true) - ) - - releaseFirstPatch() - await expect(resultPromise).resolves.toMatchObject([ - { targetId: 'target-1', result: { ok: true } }, - { targetId: 'target-2', result: { ok: true } } - ]) - }) - - it('retries once when a reset relay reports a lower revision than the cached base', async () => { - const resetTarget: SshTarget = { - id: 'target-reset', - label: 'Reset Target', - host: 'reset.example.com', - port: 22, - username: 'alice' - } - getSshConnectionStoreMock.mockReturnValue({ - listTargets: () => [resetTarget] - }) - getRepoMock.mockImplementation((repoId: string) => - repoId === 'repo-reset' - ? ({ - id: 'repo-reset', - path: '/remote/repo', - displayName: 'Repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'target-reset' - } as never) - : undefined - ) - - const patchBaseRevisions: number[] = [] - const request = vi - .fn() - .mockImplementation(async (method: string, params: Record) => { - if (method === 'workspace.get') { - return snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - } - if (method === 'workspace.patch') { - patchBaseRevisions.push(params.baseRevision as number) - if (patchBaseRevisions.length === 1) { - return { - ok: false, - reason: 'stale-revision', - snapshot: snapshot( - { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 0 - ) - } - } - return { - ok: true, - snapshot: snapshot(patchSession(params), 1) - } - } - throw new Error(`Unexpected method ${method}`) - }) - muxByTargetId.set('target-reset', { request }) - const observationToken = observeSnapshot( - 'target-reset', - snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - ) - - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-reset::/remote/workspace', 'tab-reset'), - hydratedTargetIds: ['target-reset'], - expectedRevisionsByTargetId: { 'target-reset': 7 }, - expectedHostObservationTokensByTargetId: { 'target-reset': observationToken } - }) - ).resolves.toMatchObject([{ targetId: 'target-reset', result: { ok: true } }]) - expect(patchBaseRevisions).toEqual([7, 0]) - }) - - it('does not retry stale writes when the relay reports a newer revision', async () => { - const newerTarget: SshTarget = { - id: 'target-newer', - label: 'Newer Target', - host: 'newer.example.com', - port: 22, - username: 'alice' - } - getSshConnectionStoreMock.mockReturnValue({ - listTargets: () => [newerTarget] - }) - getRepoMock.mockImplementation((repoId: string) => - repoId === 'repo-newer' - ? ({ - id: 'repo-newer', - path: '/remote/repo', - displayName: 'Repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'target-newer' - } as never) - : undefined - ) - - const patchBaseRevisions: number[] = [] - const request = vi - .fn() - .mockImplementation(async (method: string, params: Record) => { - if (method === 'workspace.get') { - return snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - } - if (method === 'workspace.patch') { - patchBaseRevisions.push(params.baseRevision as number) - return { - ok: false, - reason: 'stale-revision', - snapshot: snapshot( - { - activeWorktreePath: '/other-device', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 8 - ) - } - } - throw new Error(`Unexpected method ${method}`) - }) - muxByTargetId.set('target-newer', { request }) - const observationToken = observeSnapshot( - 'target-newer', - snapshot( - { - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }, - 7 - ) - ) - - await expect( - callSetForConnectedTargets({ - session: sessionWithTab('repo-newer::/remote/workspace', 'tab-local'), - hydratedTargetIds: ['target-newer'], - expectedRevisionsByTargetId: { 'target-newer': 7 }, - expectedHostObservationTokensByTargetId: { 'target-newer': observationToken } - }) - ).resolves.toMatchObject([ - { targetId: 'target-newer', result: { ok: false, reason: 'stale-revision' } } - ]) - expect(patchBaseRevisions).toEqual([7]) - }) -}) diff --git a/src/main/ipc/remote-workspace-patch-queue.ts b/src/main/ipc/remote-workspace-patch-queue.ts deleted file mode 100644 index 14205a3992d3..000000000000 --- a/src/main/ipc/remote-workspace-patch-queue.ts +++ /dev/null @@ -1,32 +0,0 @@ -const remoteWorkspacePatchTailByTargetId = new Map>() - -export async function queueRemoteWorkspacePatch( - targetId: string, - operation: () => Promise -): Promise { - const previous = remoteWorkspacePatchTailByTargetId.get(targetId) ?? Promise.resolve() - let release!: () => void - const tail = new Promise((resolve) => { - release = resolve - }) - const queued = previous.catch(() => {}).then(() => tail) - remoteWorkspacePatchTailByTargetId.set(targetId, queued) - - await previous.catch(() => {}) - try { - return await operation() - } finally { - release() - if (remoteWorkspacePatchTailByTargetId.get(targetId) === queued) { - remoteWorkspacePatchTailByTargetId.delete(targetId) - } - } -} - -export function clearRemoteWorkspacePatchTails(): void { - remoteWorkspacePatchTailByTargetId.clear() -} - -export function getRemoteWorkspacePatchTailCount(): number { - return remoteWorkspacePatchTailByTargetId.size -} diff --git a/src/main/ipc/remote-workspace-relay-sync.ts b/src/main/ipc/remote-workspace-relay-sync.ts deleted file mode 100644 index 428522240900..000000000000 --- a/src/main/ipc/remote-workspace-relay-sync.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot, - RemoteWorkspacePatchResult, - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' -import { getActiveMultiplexer } from './ssh' -import { CLIENT_ID } from './remote-workspace-client-identity' -import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' -import { - getCachedRemoteWorkspaceSnapshot, - rememberLocallyPatchedRemoteWorkspaceSnapshot, - rememberRemoteWorkspaceSnapshot -} from './remote-workspace-snapshot-cache' -import { - normalizeSnapshot, - remoteWorkspaceSessionMatchesSnapshot -} from './remote-workspace-snapshot-normalization' - -// Keep comparison and cache mutation together in the response continuation. -export async function readRemoteSnapshot( - target: SshTarget, - receive: (snapshot: RemoteWorkspaceSnapshot) => Result -): Promise { - const mux = getActiveMultiplexer(target.id) - if (!mux) { - return null - } - const namespace = getRemoteWorkspaceNamespace(target) - try { - const raw = await mux.request('workspace.get', { namespace }) - return receive(normalizeSnapshot(raw, namespace)) - } catch (err) { - if ((err as { code?: unknown })?.code === -32601) { - return null - } - throw err - } -} - -export function getRemoteSnapshot( - target: SshTarget -): Promise { - return readRemoteSnapshot(target, (snapshot) => - rememberRemoteWorkspaceSnapshot(target.id, snapshot) - ) -} - -function observePatchResult( - targetId: string, - result: RemoteWorkspacePatchResult -): RemoteWorkspaceObservedPatchResult { - if (result.ok) { - return { - ok: true, - snapshot: rememberLocallyPatchedRemoteWorkspaceSnapshot(targetId, result.snapshot) - } - } - const failure = { - ok: false as const, - reason: result.reason, - ...(result.message !== undefined ? { message: result.message } : {}) - } - return result.snapshot - ? { - ...failure, - snapshot: rememberRemoteWorkspaceSnapshot(targetId, result.snapshot) - } - : failure -} - -export async function patchRemoteWorkspaceSession( - target: SshTarget, - session: RemoteWorkspaceSession -): Promise { - const mux = getActiveMultiplexer(target.id) - if (!mux) { - return null - } - const namespace = getRemoteWorkspaceNamespace(target) - const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) ?? undefined - if (current && remoteWorkspaceSessionMatchesSnapshot(current, session)) { - // Why: a pulled workspace snapshot rehydrates local state and can trigger - // session persistence. Identical target sessions must stay a local no-op or - // two clients will echo revisions indefinitely. - return { ok: true, snapshot: current } - } - - const requestPatch = async ( - baseRevision: number | undefined - ): Promise => { - try { - return (await mux.request('workspace.patch', { - namespace, - baseRevision: baseRevision ?? 0, - clientId: CLIENT_ID, - patch: { kind: 'replace-session', session } - })) as RemoteWorkspacePatchResult - } catch (err) { - return (err as { code?: unknown })?.code === -32601 - ? { - ok: false, - reason: 'unavailable', - message: 'Remote workspace sync is unavailable on this relay' - } - : { - ok: false, - reason: 'unavailable', - message: err instanceof Error ? err.message : 'Remote workspace sync failed' - } - } - } - - const result = observePatchResult(target.id, await requestPatch(current?.revision)) - if (result.ok) { - return result - } - - if ( - result.reason === 'stale-revision' && - current && - result.snapshot && - result.snapshot.revision < current.revision - ) { - if (remoteWorkspaceSessionMatchesSnapshot(result.snapshot, session)) { - return { ok: true, snapshot: result.snapshot } - } - // Why: a relay reset can legitimately move the remote snapshot revision - // backwards while this process still has the old cached revision. Retrying - // only for backwards revisions restores the blank-slate target without - // overwriting a newer snapshot from another device. - return observePatchResult(target.id, await requestPatch(result.snapshot.revision)) - } - - return result -} diff --git a/src/main/ipc/remote-workspace-snapshot-cache.ts b/src/main/ipc/remote-workspace-snapshot-cache.ts deleted file mode 100644 index 2f9e26c5e81a..000000000000 --- a/src/main/ipc/remote-workspace-snapshot-cache.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { isDeepStrictEqual } from 'node:util' -import type { - RemoteWorkspaceObservedSnapshot, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' -import { normalizeSnapshot } from './remote-workspace-snapshot-normalization' - -export const REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES = 64 - -type RemoteWorkspaceSnapshotCacheEntry = { - snapshot: RemoteWorkspaceObservedSnapshot - // Why: overlapping renderer writes retain their applied base until earlier same-client patches acknowledge. - minimumAuthorizedRevision: number - maximumAuthorizedRevision: number -} - -const latestSnapshotByTargetId = new Map() - -export function remoteWorkspaceSnapshotsAreIdentical( - previous: RemoteWorkspaceSnapshot | undefined, - next: RemoteWorkspaceSnapshot -): boolean { - return ( - previous !== undefined && - previous.namespace === next.namespace && - previous.revision === next.revision && - previous.updatedAt === next.updatedAt && - previous.schemaVersion === next.schemaVersion && - // Why no scalar-only fast path: same revision with different session content is a - // genuinely new host observation (new token, reset auth window) — the patch-queue - // and cache tests pin this. Skipping the walk here mis-authorizes local patches. - isDeepStrictEqual(previous.session, next.session) - ) -} - -function rememberRemoteWorkspaceSnapshotEntry( - targetId: string, - entry: RemoteWorkspaceSnapshotCacheEntry -): void { - if (latestSnapshotByTargetId.has(targetId)) { - latestSnapshotByTargetId.delete(targetId) - } - latestSnapshotByTargetId.set(targetId, entry) - while (latestSnapshotByTargetId.size > REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES) { - const oldest = latestSnapshotByTargetId.keys().next() - if (oldest.done) { - break - } - latestSnapshotByTargetId.delete(oldest.value) - } -} - -export function rememberRemoteWorkspaceSnapshot( - targetId: string, - snapshot: RemoteWorkspaceSnapshot -): RemoteWorkspaceObservedSnapshot { - // Relay responses can carry legacy empty optional fields that normalization - // removes on reads. Keep one canonical shape in the cache so equivalent - // observations do not revoke an in-flight upload authority. - const normalizedSnapshot = normalizeSnapshot(snapshot, snapshot.namespace) - const current = latestSnapshotByTargetId.get(targetId) - if (current && remoteWorkspaceSnapshotsAreIdentical(current.snapshot, normalizedSnapshot)) { - // Re-reading an unchanged revision is not a new host observation. Keep the - // token (and the contiguous local-patch authorization window) stable so a - // polling read cannot invalidate an upload that is already in flight. - const observedSnapshot = { - ...normalizedSnapshot, - hostObservationToken: current.snapshot.hostObservationToken - } - rememberRemoteWorkspaceSnapshotEntry(targetId, { - ...current, - snapshot: observedSnapshot - }) - return observedSnapshot - } - const observedSnapshot = { ...normalizedSnapshot, hostObservationToken: randomUUID() } - rememberRemoteWorkspaceSnapshotEntry(targetId, { - snapshot: observedSnapshot, - minimumAuthorizedRevision: normalizedSnapshot.revision, - maximumAuthorizedRevision: normalizedSnapshot.revision - }) - return observedSnapshot -} - -export function rememberLocallyPatchedRemoteWorkspaceSnapshot( - targetId: string, - snapshot: RemoteWorkspaceSnapshot -): RemoteWorkspaceObservedSnapshot { - const normalizedSnapshot = normalizeSnapshot(snapshot, snapshot.namespace) - const current = latestSnapshotByTargetId.get(targetId) - if (!current || normalizedSnapshot.revision > current.maximumAuthorizedRevision + 1) { - return rememberRemoteWorkspaceSnapshot(targetId, normalizedSnapshot) - } - if (normalizedSnapshot.revision < current.snapshot.revision) { - rememberRemoteWorkspaceSnapshotEntry(targetId, current) - return current.snapshot - } - const observedSnapshot = { - ...normalizedSnapshot, - hostObservationToken: current.snapshot.hostObservationToken - } - rememberRemoteWorkspaceSnapshotEntry(targetId, { - snapshot: observedSnapshot, - minimumAuthorizedRevision: current.minimumAuthorizedRevision, - maximumAuthorizedRevision: Math.max( - current.maximumAuthorizedRevision, - normalizedSnapshot.revision - ) - }) - return observedSnapshot -} - -export function getCachedRemoteWorkspaceSnapshot( - targetId: string -): RemoteWorkspaceObservedSnapshot | undefined { - const entry = latestSnapshotByTargetId.get(targetId) - if (!entry) { - return undefined - } - // Why: remote workspace snapshots can contain the whole tab/layout session - // for a target. Touch cache hits so deleted or rarely used targets age out. - rememberRemoteWorkspaceSnapshotEntry(targetId, entry) - return entry.snapshot -} - -export function cachedRemoteWorkspaceSnapshotAuthorizesRevision( - targetId: string, - revision: number -): boolean { - const entry = latestSnapshotByTargetId.get(targetId) - return ( - entry !== undefined && - revision >= entry.minimumAuthorizedRevision && - revision <= entry.maximumAuthorizedRevision - ) -} - -export function clearRemoteWorkspaceSnapshotCache(): void { - latestSnapshotByTargetId.clear() -} - -export function getRemoteWorkspaceSnapshotCacheSize(): number { - return latestSnapshotByTargetId.size -} - -/** @internal - exposed for cache-bound tests only. */ -export function _rememberRemoteWorkspaceSnapshotForTests( - targetId: string, - snapshot: RemoteWorkspaceSnapshot -): void { - rememberRemoteWorkspaceSnapshot(targetId, snapshot) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _getRemoteWorkspaceSnapshotForTests( - targetId: string -): RemoteWorkspaceObservedSnapshot | undefined { - return getCachedRemoteWorkspaceSnapshot(targetId) -} diff --git a/src/main/ipc/remote-workspace-snapshot-normalization.test.ts b/src/main/ipc/remote-workspace-snapshot-normalization.test.ts deleted file mode 100644 index 7f57b4328135..000000000000 --- a/src/main/ipc/remote-workspace-snapshot-normalization.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - normalizeSnapshot, - remoteWorkspaceSessionMatchesSnapshot -} from './remote-workspace-snapshot-normalization' -import type { - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' - -function session(overrides: Partial = {}): RemoteWorkspaceSession { - return { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {}, - ...overrides - } -} - -function snapshotOf(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot { - return { namespace: 'ns', revision: 3, updatedAt: 1, schemaVersion: 1, session } -} - -/** - * Every declared field, each holding a value the normalizer must neither drop nor flatten. - * - * `Required` is the ratchet: normalizeRemoteSession rebuilds the session as an object literal, so a - * field added to the wire type and forgotten there is invisible — it round-trips through the - * projection, reaches the relay, and is stripped on the way back. Declaring the fixture Required - * makes the next such field a compile error here before it can become a silent data loss. - */ -const everyDeclaredField: Required = { - activeWorktreePath: '/repo', - activeTabId: 'tab-1', - tabsByWorktreePath: { '/repo': [{ id: 'tab-1', type: 'terminal', title: 'Shell' } as never] }, - terminalLayoutsByTabId: { 'tab-1': { kind: 'leaf', ptyId: 'pty-1' } as never }, - activeWorktreePathsOnShutdown: ['/repo'], - activeTabIdByWorktreePath: { '/repo': 'tab-1' }, - remoteSessionIdsByTabId: { 'tab-1': 'session-1' }, - lastVisitedAtByWorktreePath: { '/repo': 42 }, - defaultTerminalTabsAppliedByWorktreePath: { '/repo': true } -} - -describe('normalizeSnapshot: field coverage', () => { - it('copies every declared field of the wire session', () => { - const normalized = normalizeSnapshot(snapshotOf(everyDeclaredField), 'ns').session - - expect(Object.keys(normalized).sort()).toEqual(Object.keys(everyDeclaredField).sort()) - expect(normalized).toEqual(everyDeclaredField) - }) -}) - -describe('normalizeSnapshot: defaultTerminalTabsAppliedByWorktreePath', () => { - it('carries the marker through instead of dropping it', () => { - // The renderer exports this field and its importer reads it, but the normalizer every read - // passes through never copied it — so the flag reached the relay and could never come back. - const raw = snapshotOf(session({ defaultTerminalTabsAppliedByWorktreePath: { '/repo': true } })) - - expect(normalizeSnapshot(raw, 'ns').session.defaultTerminalTabsAppliedByWorktreePath).toEqual({ - '/repo': true - }) - }) - - it('treats an empty marker record as absent, like every other optional record', () => { - // The export always emits an object, so {} and absent both mean "no worktree has applied - // them". Collapsing them keeps a no-op export from looking like a change to the patch guard. - const raw = snapshotOf(session({ defaultTerminalTabsAppliedByWorktreePath: {} })) - - expect( - normalizeSnapshot(raw, 'ns').session.defaultTerminalTabsAppliedByWorktreePath - ).toBeUndefined() - }) -}) - -describe('remoteWorkspaceSessionMatchesSnapshot', () => { - it('sees a change that only sets the default-terminal-tabs marker', () => { - // patchRemoteWorkspaceSession short-circuits on this comparison, so a field it cannot see is a - // field that can never be written at all. - expect( - remoteWorkspaceSessionMatchesSnapshot( - snapshotOf(session()), - session({ defaultTerminalTabsAppliedByWorktreePath: { '/repo': true } }) - ) - ).toBe(false) - }) -}) diff --git a/src/main/ipc/remote-workspace-snapshot-normalization.ts b/src/main/ipc/remote-workspace-snapshot-normalization.ts deleted file mode 100644 index 749d83cc0663..000000000000 --- a/src/main/ipc/remote-workspace-snapshot-normalization.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' -import type { - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' - -const SNAPSHOT_SCHEMA_VERSION = 1 - -function emptyRemoteSession(): RemoteWorkspaceSession { - return { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - } -} - -function normalizeOptionalStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined - } - const normalized = value.filter((entry): entry is string => typeof entry === 'string') - return normalized.length > 0 ? normalized : undefined -} - -function normalizeOptionalRecord>(value: unknown): T | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined - } - return Object.keys(value).length > 0 ? (value as T) : undefined -} - -function normalizeRemoteSession(raw: unknown): RemoteWorkspaceSession { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return emptyRemoteSession() - } - const input = raw as Partial - return { - activeWorktreePath: - typeof input.activeWorktreePath === 'string' ? input.activeWorktreePath : null, - activeTabId: typeof input.activeTabId === 'string' ? input.activeTabId : null, - tabsByWorktreePath: - input.tabsByWorktreePath && - typeof input.tabsByWorktreePath === 'object' && - !Array.isArray(input.tabsByWorktreePath) - ? input.tabsByWorktreePath - : {}, - terminalLayoutsByTabId: - input.terminalLayoutsByTabId && - typeof input.terminalLayoutsByTabId === 'object' && - !Array.isArray(input.terminalLayoutsByTabId) - ? input.terminalLayoutsByTabId - : {}, - activeWorktreePathsOnShutdown: normalizeOptionalStringArray( - input.activeWorktreePathsOnShutdown - ), - activeTabIdByWorktreePath: normalizeOptionalRecord>( - input.activeTabIdByWorktreePath - ), - remoteSessionIdsByTabId: normalizeOptionalRecord>( - input.remoteSessionIdsByTabId - ), - lastVisitedAtByWorktreePath: normalizeOptionalRecord>( - input.lastVisitedAtByWorktreePath - ), - // Dropping this made the field write-only: every read stripped it, and the patch guard below - // compares normalized sessions, so a change that only sets it looked like no change at all. - defaultTerminalTabsAppliedByWorktreePath: normalizeOptionalRecord>( - input.defaultTerminalTabsAppliedByWorktreePath - ) - } -} - -export function normalizeSnapshot( - raw: unknown, - fallbackNamespace: string -): RemoteWorkspaceSnapshot { - const input = raw as Partial | null - return { - namespace: typeof input?.namespace === 'string' ? input.namespace : fallbackNamespace, - revision: - typeof input?.revision === 'number' && Number.isFinite(input.revision) ? input.revision : 0, - updatedAt: - typeof input?.updatedAt === 'number' && Number.isFinite(input.updatedAt) - ? input.updatedAt - : 0, - schemaVersion: - typeof input?.schemaVersion === 'number' && Number.isFinite(input.schemaVersion) - ? input.schemaVersion - : SNAPSHOT_SCHEMA_VERSION, - session: normalizeRemoteSession(input?.session) - } -} - -export function remoteWorkspaceSessionMatchesSnapshot( - snapshot: RemoteWorkspaceSnapshot | undefined, - session: RemoteWorkspaceSession -): boolean { - if (!snapshot) { - return false - } - return isDeepStrictEqual( - normalizeRemoteSession(snapshot.session), - normalizeRemoteSession(session) - ) -} diff --git a/src/main/ipc/remote-workspace-stale-resync.test.ts b/src/main/ipc/remote-workspace-stale-resync.test.ts deleted file mode 100644 index b87a9a9893f3..000000000000 --- a/src/main/ipc/remote-workspace-stale-resync.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { encodeJsonRpcFrame } from '../ssh/relay-protocol' -import { - REMOTE_WORKSPACE_STALE_NOTIFICATION, - REMOTE_WORKSPACE_CHANGED_NOTIFICATION, - type RemoteWorkspaceChangedEvent, - type RemoteWorkspaceSession, - type RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' - -const { getActiveMultiplexerMock, getSshConnectionStoreMock } = vi.hoisted(() => ({ - getActiveMultiplexerMock: vi.fn(), - getSshConnectionStoreMock: vi.fn() -})) - -vi.mock('electron', () => ({ - ipcMain: { handle: vi.fn(), removeHandler: vi.fn() } -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, - getSshConnectionStore: getSshConnectionStoreMock -})) - -vi.mock('./remote-workspace-events', () => ({ - registerRemoteWorkspaceNotificationHandler: vi.fn(() => vi.fn()) -})) - -import { - _resetRemoteWorkspaceCachesForTests, - handleRemoteWorkspaceNotification, - registerRemoteWorkspaceHandlers -} from './remote-workspace' -import { - getCachedRemoteWorkspaceSnapshot, - rememberLocallyPatchedRemoteWorkspaceSnapshot, - rememberRemoteWorkspaceSnapshot -} from './remote-workspace-snapshot-cache' -import { isRemoteWorkspaceResyncInFlight } from './remote-workspace-stale-resync' -import { CLIENT_ID } from './remote-workspace-client-identity' - -function session(activeTabId: string): RemoteWorkspaceSession { - return { - activeWorktreePath: '/remote/worktree', - activeTabId, - tabsByWorktreePath: { - '/remote/worktree': [{ id: activeTabId, worktreePath: '/remote/worktree' } as never] - }, - terminalLayoutsByTabId: {} - } -} - -function snapshot(revision: number, tabId: string): RemoteWorkspaceSnapshot { - return { - namespace: 'target-1', - revision, - updatedAt: revision, - schemaVersion: 1, - session: session(tabId) - } -} - -describe('workspace.stale resync', () => { - const sent: RemoteWorkspaceChangedEvent[] = [] - const request = vi.fn() - const store = { getRepo: vi.fn(), getWorkspaceSession: vi.fn() } as unknown as Store - - beforeEach(() => { - sent.length = 0 - request.mockReset() - _resetRemoteWorkspaceCachesForTests() - getActiveMultiplexerMock.mockReset() - getActiveMultiplexerMock.mockImplementation(() => ({ request })) - getSshConnectionStoreMock.mockReset() - getSshConnectionStoreMock.mockImplementation(() => ({ - getTarget: (id: string) => ({ id, host: 'example.test', username: 'dev' }), - listTargets: () => [] - })) - const win = { - isDestroyed: () => false, - webContents: { - send: (_channel: string, event: RemoteWorkspaceChangedEvent) => sent.push(event) - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window fake exposes only the webContents.send the handlers call. - registerRemoteWorkspaceHandlers(store, () => win as never, { - readMachineName: () => 'Build server' - }) - }) - - it('re-reads the snapshot through workspace.get and publishes it to the renderer', async () => { - request.mockResolvedValue({ - namespace: 'target-1', - revision: 12, - updatedAt: 5, - schemaVersion: 1, - session: session('tab-from-other-device') - }) - - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - await vi.waitFor(() => expect(sent).toHaveLength(1)) - - expect(request).toHaveBeenCalledWith('workspace.get', { namespace: expect.any(String) }) - expect(sent[0].targetId).toBe('target-1') - expect(sent[0].snapshot.revision).toBe(12) - expect(sent[0].snapshot.session.activeTabId).toBe('tab-from-other-device') - // The marker names no author, so the renderer's own-echo filter must not discard the resync. - expect(sent[0].sourceClientId).toBeUndefined() - }) - - it('collapses a burst of markers into one extra read rather than one read per marker', async () => { - const released: ((value: unknown) => void)[] = [] - request.mockImplementation( - () => - new Promise((resolve) => { - released.push(resolve) - }) - ) - - for (let i = 0; i < 4; i++) { - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - } - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)) - - request.mockResolvedValue({ - namespace: 'target-1', - revision: 3, - updatedAt: 1, - schemaVersion: 1, - session: session('tab-a') - }) - released[0]?.({ - namespace: 'target-1', - revision: 2, - updatedAt: 1, - schemaVersion: 1, - session: session('tab-a') - }) - - // Exactly one follow-up read for the markers that landed mid-flight: never zero, never four. - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)) - await Promise.resolve() - expect(request).toHaveBeenCalledTimes(2) - }) - - it('stays silent when the re-read finds the session it already had', async () => { - request.mockResolvedValue({ - namespace: 'target-1', - revision: 4, - updatedAt: 1, - schemaVersion: 1, - session: session('tab-a') - }) - - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => expect(sent).toHaveLength(1)) - - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)) - await Promise.resolve() - expect(sent).toHaveLength(1) - }) - - it.each(['own', 'peer'] as const)( - 'checks a %s snapshot against the own patch reply received during its pending read', - async (source) => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'tab-before-patch')) - const ownSnapshot = snapshot(2, 'tab-from-own-patch') - const readSnapshot = source === 'own' ? ownSnapshot : snapshot(3, 'tab-from-peer') - request.mockResolvedValue(readSnapshot) - let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - expect(request).toHaveBeenCalledTimes(1) - expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(true) - - // The relay publishes the stale marker before returning this client's patch reply. - rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) - releaseRead?.(readSnapshot) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual( - source === 'own' ? [] : ['tab-from-peer'] - ) - } - ) - - it('delivers a peer snapshot cached by a stale-revision patch reply during the read', async () => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'tab-before-peer')) - const peerSnapshot = snapshot(2, 'tab-from-peer') - let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - // A rejected local patch caches the peer snapshot, but the renderer only records a conflict. - rememberRemoteWorkspaceSnapshot('target-1', peerSnapshot) - releaseRead?.(peerSnapshot) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['tab-from-peer']) - expect(request).toHaveBeenCalledTimes(1) - }) - - it('suppresses an own reply already acknowledged before the marker', async () => { - const ownSnapshot = snapshot(2, 'tab-from-own-patch') - rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) - request.mockResolvedValue(ownSnapshot) - - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(sent).toEqual([]) - }) - - it('still reads a queued peer change after suppressing the own echo', async () => { - const ownSnapshot = snapshot(2, 'tab-from-own-patch') - let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - request.mockResolvedValue(snapshot(3, 'tab-from-peer')) - - for (let index = 0; index < 2; index += 1) { - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - } - - rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) - releaseRead?.(ownSnapshot) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(request).toHaveBeenCalledTimes(2) - expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['tab-from-peer']) - }) - - it('keeps an own acknowledgement from suppressing another target', async () => { - const releases: ((value: RemoteWorkspaceSnapshot) => void)[] = [] - request.mockImplementation( - () => new Promise((resolve) => releases.push(resolve)) - ) - for (const targetId of ['target-1', 'target-2']) { - handleRemoteWorkspaceNotification(targetId, REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - } - const ownSnapshot = snapshot(2, 'same-tab') - rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) - releases[0]?.(ownSnapshot) - releases[1]?.(ownSnapshot) - await vi.waitFor(() => { - expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false) - expect(isRemoteWorkspaceResyncInFlight('target-2')).toBe(false) - }) - - expect(sent.map((event) => event.targetId)).toEqual(['target-2']) - }) - - it('does not deliver a captured read after a same-token own acknowledgement advances the cache', async () => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'initial-tab')) - request.mockResolvedValue(snapshot(3, 'newer-own-tab')) - let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - - releaseRead?.(snapshot(2, 'peer-tab')) - queueMicrotask(() => { - rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', snapshot(3, 'newer-own-tab')) - }) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(3) - expect(sent.map((event) => event.snapshot.revision)).toEqual([]) - expect(request).toHaveBeenCalledTimes(2) - }) - - it.each([ - { source: 'peer', order: 'response-first' }, - { source: 'peer', order: 'notification-first' }, - { source: 'own', order: 'response-first' }, - { source: 'own', order: 'notification-first' } - ])( - 'never publishes an older read after a newer $source notification ($order)', - async ({ source, order }) => { - let receive: ((data: Buffer) => void) | undefined - // Keep both frames in one decoder pass for the ordering assertions. - const deliveryClock = vi.spyOn(Date, 'now').mockReturnValue(Date.now()) - const mux = new SshChannelMultiplexer({ - write: () => {}, - onData: (callback) => { - receive = callback - }, - onClose: () => {} - }) - const read = vi.spyOn(mux, 'request') - getActiveMultiplexerMock.mockReturnValue(mux) - mux.onNotification((method, params) => - handleRemoteWorkspaceNotification('target-1', method, params) - ) - const initial = rememberRemoteWorkspaceSnapshot('target-1', snapshot(2, 'initial-tab')) - const sourceClientId = source === 'own' ? CLIENT_ID : 'peer-client' - try { - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - const reply = { jsonrpc: '2.0', id: 1, result: snapshot(2, 'older-read') } as const - const changed = { - jsonrpc: '2.0', - method: REMOTE_WORKSPACE_CHANGED_NOTIFICATION, - params: { snapshot: snapshot(3, 'newer-source'), sourceClientId } - } as const - const messages = order === 'response-first' ? [reply, changed] : [changed, reply] - receive?.( - Buffer.concat(messages.map((message, index) => encodeJsonRpcFrame(message, index + 1, 0))) - ) - deliveryClock.mockRestore() - if (source === 'own') { - expect(getCachedRemoteWorkspaceSnapshot('target-1')?.hostObservationToken).toBe( - initial.hostObservationToken - ) - } - await new Promise((resolve) => setImmediate(resolve)) - expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(3) - expect(sent.map((event) => event.snapshot.revision)).toEqual([3]) - await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) - receive?.( - encodeJsonRpcFrame({ jsonrpc: '2.0', id: 2, result: snapshot(3, 'newer-source') }, 3, 0) - ) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - expect(sent.map((event) => event.snapshot.revision)).toEqual([3]) - expect(sent[0].sourceClientId).toBe(sourceClientId) - } finally { - deliveryClock.mockRestore() - mux.dispose() - } - } - ) - - it.each([2, 3])( - 'does not deliver a captured read after peer revision %i replaces it', - async (peerRevision) => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'initial-tab')) - const peer = snapshot(peerRevision, 'newer-peer') - request.mockResolvedValue(peer) - let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - - releaseRead?.(snapshot(2, 'older-read')) - queueMicrotask(() => { - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_CHANGED_NOTIFICATION, { - snapshot: peer, - sourceClientId: 'peer-client' - }) - }) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(getCachedRemoteWorkspaceSnapshot('target-1')?.session.activeTabId).toBe('newer-peer') - expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['newer-peer']) - expect(request).toHaveBeenCalledTimes(2) - } - ) - - it('accepts a relay reset after rereading a response that raced a host change', async () => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(40, 'before-reset')) - request.mockResolvedValue(snapshot(1, 'after-reset')) - let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_CHANGED_NOTIFICATION, { - snapshot: snapshot(41, 'last-before-reset'), - sourceClientId: 'peer-client' - }) - releaseRead?.(snapshot(1, 'after-reset')) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - - expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(1) - expect(sent.map((event) => event.snapshot.revision)).toEqual([41, 1]) - expect(request).toHaveBeenCalledTimes(2) - }) - - it('still accepts a lower revision after a relay reset', async () => { - rememberRemoteWorkspaceSnapshot('target-1', snapshot(40, 'before-reset')) - request.mockResolvedValue(snapshot(1, 'after-reset')) - handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { - namespace: 'target-1' - }) - await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) - expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(1) - expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['after-reset']) - expect(request).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/ipc/remote-workspace-stale-resync.ts b/src/main/ipc/remote-workspace-stale-resync.ts deleted file mode 100644 index 93f25722df24..000000000000 --- a/src/main/ipc/remote-workspace-stale-resync.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { RemoteWorkspaceObservedSnapshot } from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' -import { readRemoteSnapshot } from './remote-workspace-relay-sync' -import { - getCachedRemoteWorkspaceSnapshot, - remoteWorkspaceSnapshotsAreIdentical, - rememberRemoteWorkspaceSnapshot -} from './remote-workspace-snapshot-cache' -import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' - -type PendingResync = { promise: Promise; requeued: boolean } - -const pendingByTargetId = new Map() - -export function _resetRemoteWorkspaceStaleResyncForTests(): void { - pendingByTargetId.clear() -} - -export function isRemoteWorkspaceResyncInFlight(targetId: string): boolean { - return pendingByTargetId.has(targetId) -} - -/** - * The relay told us it could not deliver a snapshot, so pull it. `workspace.get` is a response, and - * responses are admitted against the megabyte-scale control/legacy-response budget rather than the - * single ~12KB producer frame that refused the broadcast — the payload was never too big for the - * link, only for that one lane. - */ -export function resyncStaleRemoteWorkspace( - target: SshTarget, - deliver: (snapshot: RemoteWorkspaceObservedSnapshot) => void, - onError: (error: unknown) => void = () => {} -): Promise { - const existing = pendingByTargetId.get(target.id) - if (existing) { - // Why: a burst of markers must collapse to one extra read, but never to zero — a marker that - // arrived while a read was already in flight may describe a revision that read did not see. - existing.requeued = true - return existing.promise - } - const pending: PendingResync = { requeued: false, promise: Promise.resolve() } - pending.promise = (async () => { - try { - do { - pending.requeued = false - const cachedBeforeRead = getCachedRemoteWorkspaceSnapshot(target.id) - const observation = await readRemoteSnapshot(target, (snapshot) => { - // An own patch reply can update the cache while this read is pending. - const previous = getCachedRemoteWorkspaceSnapshot(target.id) - const changedDuringRead = cachedBeforeRead - ? !remoteWorkspaceSnapshotsAreIdentical(previous, cachedBeforeRead) - : previous !== undefined - if (changedDuringRead) { - if (!remoteWorkspaceSnapshotsAreIdentical(previous, snapshot)) { - // Reread a conflicting observation; revision comparisons would reject valid relay resets. - pending.requeued = true - return null - } - // Only a same-token own ack proves the renderer has this; a stale-revision reply caches undelivered peer state. - if ( - !cachedBeforeRead || - previous?.hostObservationToken === cachedBeforeRead.hostObservationToken - ) { - return null - } - } - return { - unchanged: - !changedDuringRead && - remoteWorkspaceSessionMatchesSnapshot(previous, snapshot.session), - snapshot: rememberRemoteWorkspaceSnapshot(target.id, snapshot) - } - }) - if (!observation) { - continue - } - // Suppress the echo: our own patch response already cached this session, and re-publishing it - // makes the renderer rehydrate a state it authored. - if (observation.unchanged) { - continue - } - const latest = getCachedRemoteWorkspaceSnapshot(target.id) - if (!remoteWorkspaceSnapshotsAreIdentical(latest, observation.snapshot)) { - pending.requeued = true - continue - } - deliver(observation.snapshot) - } while (pending.requeued) - } catch (error) { - onError(error) - } finally { - pendingByTargetId.delete(target.id) - } - })() - pendingByTargetId.set(target.id, pending) - return pending.promise -} diff --git a/src/main/ipc/remote-workspace-target-session-export.test.ts b/src/main/ipc/remote-workspace-target-session-export.test.ts deleted file mode 100644 index aa60c2d2929e..000000000000 --- a/src/main/ipc/remote-workspace-target-session-export.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Store } from '../persistence' -import type { Repo } from '../../shared/repo-types' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { TerminalTab } from '../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' -import { - createWorktreeOwnerResolver, - persistedSessionForTarget -} from './remote-workspace-target-session-export' - -const TARGET_ID = 'target-1' -const WORKTREE_ID = 'repo-1::/remote/repo' - -function tab(id: string): TerminalTab { - return { - id, - ptyId: null, - worktreeId: WORKTREE_ID, - title: id, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -function session(tabs: TerminalTab[]): WorkspaceSessionState { - return { ...getDefaultWorkspaceSession(), tabsByWorktree: { [WORKTREE_ID]: tabs } } -} - -describe('persistedSessionForTarget', () => { - it("publishes the ssh partition's tabs, not a stray local copy of an SSH-owned workspace", () => { - const partitions: Record = { - local: session([tab('tab-stale')]), - [`ssh:${TARGET_ID}`]: session([tab('tab-live')]) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the publish fallback reads only getWorkspaceSession. - const store = { - getWorkspaceSession: (hostId?: string) => partitions[hostId ?? 'local'] - } as unknown as Store - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership resolution reads only id, connectionId and executionHostId. - const repos = [{ id: 'repo-1', connectionId: TARGET_ID, executionHostId: null }] as Repo[] - - const published = persistedSessionForTarget( - store, - TARGET_ID, - createWorktreeOwnerResolver(createRepoRowExecutionHostLookup(repos)) - ) - - expect(published.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live']) - }) -}) diff --git a/src/main/ipc/remote-workspace-target-session-export.ts b/src/main/ipc/remote-workspace-target-session-export.ts deleted file mode 100644 index 9adef9312e93..000000000000 --- a/src/main/ipc/remote-workspace-target-session-export.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { Store } from '../persistence' -import type { Repo } from '../../shared/repo-types' -import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' -import type { RemoteWorkspaceSession } from '../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import { - adoptStrandedHostPartitionSession, - workspaceIdsNamedByPartition -} from '../../shared/workspace-session-stranded-partition-adoption' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { - resolveWorktreeExecutionHost, - type createRepoRowExecutionHostLookup, - type WorktreeExecutionHostResolution -} from '../../shared/worktree-execution-host-resolution' - -type RepoRowLookup = ReturnType> - -/** Which target a workspace session is exported to. */ -export type WorktreeTargetResolver = (worktreeId: string, executionHostId?: string) => string | null - -/** Who owns a workspace, answered from the repo catalog alone. */ -export type WorktreeOwnerResolver = ( - worktreeId: string, - executionHostId?: string -) => WorktreeExecutionHostResolution - -/** - * Resolve each workspace's owner at most once for a whole publish. - * - * Why this is shared and not per target: ownership comes from the repo catalog alone — only the - * final `=== targetId` differs — so exporting to N targets used to repeat the identical resolution - * N times over every worktree key. `store.getRepos()` also re-hydrates every repo row on each - * call, and the projection asks this question once per key of `tabsByWorktree`, - * `activeTabIdByWorktree`, `lastVisitedAtByWorktreeId` and `defaultTerminalTabsAppliedByWorktreeId` - * — and the publish fallback's catalog attribution asks it again for the same keys. - */ -export function createWorktreeOwnerResolver(repoLookup: RepoRowLookup): WorktreeOwnerResolver { - const resolved = new Map>() - return (worktreeId, executionHostId) => { - // Host id participates in resolution, so it has to participate in the key. NUL cannot appear - // in either id, so it is a collision-free separator. - const key = `${worktreeId}\u0000${executionHostId ?? ''}` - const cached = resolved.get(key) - if (cached) { - return cached - } - // Why: this decides which SSH target a workspace session is exported to. The old fallback read - // `getRepo(id)?.connectionId`, which is host-blind — the same repo id can name rows on several - // hosts, so a session could be published to a machine that never owned the worktree (#11163). - // Unresolvable ownership exports to nobody rather than guessing. - const resolution = resolveWorktreeExecutionHost(repoLookup, { - repoId: getRepoIdFromWorktreeId(worktreeId), - hostId: executionHostId ?? null - }) - resolved.set(key, resolution) - return resolution - } -} - -export function createWorktreeTargetResolver( - resolveWorktreeOwner: WorktreeOwnerResolver -): WorktreeTargetResolver { - return (worktreeId, executionHostId) => { - const resolution = resolveWorktreeOwner(worktreeId, executionHostId) - return resolution.kind === 'resolved' ? resolution.connectionId : null - } -} - -export function exportSessionForTarget( - resolveWorktreeTarget: WorktreeTargetResolver, - targetId: string, - session: WorkspaceSessionState -): RemoteWorkspaceSession { - return exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId, executionHostId) => - resolveWorktreeTarget(worktreeId, executionHostId) === targetId - }) -} - -/** - * The persisted session a publish speaks for when the renderer sent none. - * - * Why not `store.getWorkspaceSession()` alone: that reads the 'local' blob, and a target's - * worktrees live in `ssh:` (#12723). Publishing the local half as though it were the - * whole session uploaded explicit empty tab lists, and `replace-session` turned that absence into - * deletion on the host (#12721). Resolved per target so one target's rows can never be published - * under another's key when both partitions hold the same worktree id. - * - * The contested verdict is computed the same way the renderer's read computes it, over the same two - * partitions. Main reaching a different one would let it publish rows the renderer never displays. - */ -export function persistedSessionForTarget( - store: Store, - targetId: string, - /** Shared across the whole publish, and with the projection: one resolution per workspace id. */ - resolveWorktreeOwner: WorktreeOwnerResolver -): WorkspaceSessionState { - const hostId = toSshExecutionHostId(targetId) - const local = store.getWorkspaceSession() - const host = store.getWorkspaceSession(hostId) - return adoptStrandedHostPartitionSession(local, host, { - ...catalogAttributionForPartition(resolveWorktreeOwner, host, hostId) - }).session -} - -/** The same catalog reading the renderer's boot read applies to this partition: a repo id the - * catalog registers on more than one host is contested, and one it positively resolves to a - * different host is residue this partition does not own. Main reaching a different verdict would - * publish to the host rows the renderer never displays. */ -function catalogAttributionForPartition( - resolveWorktreeOwner: WorktreeOwnerResolver, - host: WorkspaceSessionState, - hostId: ExecutionHostId -): { - contestedSessionKeys: Set - foreignSessionKeys: Set - ownedSessionKeys: Set -} { - const contestedSessionKeys = new Set() - const foreignSessionKeys = new Set() - const ownedSessionKeys = new Set() - for (const workspaceId of workspaceIdsNamedByPartition(host)) { - // A folder key names no repo, and `getRepoIdFromWorktreeId` hands back the whole key rather - // than nothing, so the catalog would be asked about `folder:` and answer `unknown`. - // Right verdict, wasted resolution; skip it by shape instead. - if (!workspaceId.includes('::')) { - continue - } - const resolution = resolveWorktreeOwner(workspaceId) - if (resolution.kind === 'unresolved') { - if (resolution.reason === 'ambiguous') { - contestedSessionKeys.add(workspaceId) - } - } else if (resolution.hostId !== hostId) { - foreignSessionKeys.add(workspaceId) - } else { - ownedSessionKeys.add(workspaceId) - } - } - return { contestedSessionKeys, foreignSessionKeys, ownedSessionKeys } -} diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts deleted file mode 100644 index cc542920dd83..000000000000 --- a/src/main/ipc/remote-workspace.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ipcMain } from 'electron' -import type { Store } from '../persistence' -import type { - RemoteWorkspaceObservedSnapshot, - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' -import type * as WorktreeExecutionHostResolution from '../../shared/worktree-execution-host-resolution' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -const { - getActiveMultiplexerMock, - getSshConnectionStoreMock, - registerRemoteWorkspaceNotificationHandlerMock, - resolveWorktreeExecutionHostCalls -} = vi.hoisted(() => ({ - getActiveMultiplexerMock: vi.fn(), - getSshConnectionStoreMock: vi.fn(), - registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()), - resolveWorktreeExecutionHostCalls: { count: 0 } -})) - -// Counts ownership resolutions without changing any of them. -vi.mock('../../shared/worktree-execution-host-resolution', async (importOriginal) => { - const actual = (await importOriginal()) as typeof WorktreeExecutionHostResolution - return { - ...actual, - resolveWorktreeExecutionHost: ( - ...args: Parameters - ) => { - resolveWorktreeExecutionHostCalls.count += 1 - return actual.resolveWorktreeExecutionHost(...args) - } - } -}) - -vi.mock('electron', () => ({ - ipcMain: { - handle: vi.fn(), - removeHandler: vi.fn() - } -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, - getSshConnectionStore: getSshConnectionStoreMock -})) - -vi.mock('./remote-workspace-events', () => ({ - registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock -})) - -import { - _resetRemoteWorkspaceCachesForTests, - registerRemoteWorkspaceHandlers -} from './remote-workspace' -import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' - -function snapshot(session: RemoteWorkspaceSession, revision = 7): RemoteWorkspaceSnapshot { - return { - namespace: 'target', - revision, - updatedAt: 123, - schemaVersion: 1, - session - } -} - -const baseSession = { - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} -} as WorkspaceSessionState - -const targets: SshTarget[] = [ - { - id: 'target-1', - label: 'Target 1', - host: 'one.example.com', - port: 22, - username: 'alice' - }, - { - id: 'target-2', - label: 'Target 2', - host: 'two.example.com', - port: 22, - username: 'alice' - } -] - -describe('remoteWorkspaceSessionMatchesSnapshot', () => { - it('matches normalized equivalent sessions', () => { - expect( - remoteWorkspaceSessionMatchesSnapshot( - snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }), - { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: undefined, - activeTabIdByWorktreePath: undefined, - remoteSessionIdsByTabId: undefined, - lastVisitedAtByWorktreePath: undefined - } - ) - ).toBe(true) - }) - - it('treats empty optional projection fields as equivalent to absent fields', () => { - expect( - remoteWorkspaceSessionMatchesSnapshot( - snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: {}, - remoteSessionIdsByTabId: {}, - lastVisitedAtByWorktreePath: {} - }), - { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - } - ) - ).toBe(true) - }) - - it('detects actual target session changes', () => { - expect( - remoteWorkspaceSessionMatchesSnapshot( - snapshot({ - activeWorktreePath: '/repo', - activeTabId: 'tab-1', - tabsByWorktreePath: { - '/repo': [{ id: 'tab-1', type: 'terminal', title: 'Shell' } as never] - }, - terminalLayoutsByTabId: {} - }), - { - activeWorktreePath: '/repo', - activeTabId: 'tab-2', - tabsByWorktreePath: { - '/repo': [{ id: 'tab-2', type: 'terminal', title: 'Shell 2' } as never] - }, - terminalLayoutsByTabId: {} - } - ) - ).toBe(false) - }) -}) - -describe('remoteWorkspace:setForConnectedTargets', () => { - const handlers = new Map unknown>() - const requestByTargetId = new Map>() - const muxByTargetId = new Map }>() - const getRepoMock = vi.fn() - const getWorkspaceSessionMock = vi.fn() - // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. - const getReposMock = vi.fn(() => [getRepoMock('repo-target-1')].filter(Boolean)) - const store = { - getRepo: getRepoMock, - getRepos: getReposMock, - getWorkspaceSession: getWorkspaceSessionMock - } as unknown as Store - - beforeEach(() => { - _resetRemoteWorkspaceCachesForTests() - handlers.clear() - requestByTargetId.clear() - muxByTargetId.clear() - vi.mocked(ipcMain.handle).mockReset() - vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { - handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) - }) - vi.mocked(ipcMain.removeHandler).mockReset() - getSshConnectionStoreMock.mockReset() - getSshConnectionStoreMock.mockReturnValue({ - listTargets: () => targets, - getTarget: (targetId: string) => targets.find((target) => target.id === targetId) - }) - getRepoMock.mockReset() - getReposMock.mockClear() - getWorkspaceSessionMock.mockReset() - getWorkspaceSessionMock.mockReturnValue(baseSession) - getRepoMock.mockImplementation((repoId: string) => - repoId === 'repo-target-1' - ? ({ - id: 'repo-target-1', - path: '/remote/repo', - displayName: 'Repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'target-1' - } as never) - : undefined - ) - getActiveMultiplexerMock.mockReset() - getActiveMultiplexerMock.mockImplementation((targetId: string) => { - let mux = muxByTargetId.get(targetId) - if (!mux) { - const request = vi.fn().mockImplementation((method: string) => { - if (method === 'workspace.get') { - return Promise.resolve( - snapshot({ - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) - ) - } - return Promise.resolve({ - ok: true, - snapshot: snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) - }) - }) - mux = { request } - muxByTargetId.set(targetId, mux) - requestByTargetId.set(targetId, request) - } - return mux - }) - registerRemoteWorkspaceNotificationHandlerMock.mockClear() - - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) - }) - - async function callSetForConnectedTargets(args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - }): Promise { - const handler = handlers.get('remoteWorkspace:setForConnectedTargets') - if (!handler) { - throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') - } - return handler(null, args) - } - - async function observeTarget(targetId: string): Promise { - const handler = handlers.get('remoteWorkspace:get') - if (!handler) { - throw new Error('remoteWorkspace:get handler was never registered') - } - const observed = await handler(null, { targetId }) - if (!observed || typeof observed !== 'object' || !('hostObservationToken' in observed)) { - throw new Error(`remoteWorkspace:get did not observe ${targetId}`) - } - return observed as RemoteWorkspaceObservedSnapshot - } - - it('reads the repo catalog once per publish, not once per worktree', async () => { - // `store.getRepos()` re-hydrates every repo row. The export asks "is this worktree mine?" once - // per worktree, so reading the catalog inside that callback multiplied hydration by the - // worktree count — 413 on the session that surfaced this. - const worktrees = Object.fromEntries( - Array.from({ length: 12 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) - ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await observeTarget('target-1') - getReposMock.mockClear() - - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': observed.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': observed.hostObservationToken - } - }) - - expect(getReposMock).toHaveBeenCalledTimes(1) - }) - - it('resolves each worktree ownership once for the whole publish, not once per target', async () => { - // Ownership is a function of the repo catalog alone; only the final `=== targetId` differs, so - // exporting to N targets used to repeat the identical resolution N times per worktree key. - const worktrees = Object.fromEntries( - Array.from({ length: 6 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) - ) - getWorkspaceSessionMock.mockReturnValue({ - ...baseSession, - tabsByWorktree: worktrees - } as WorkspaceSessionState) - const observed = await Promise.all(targets.map((target) => observeTarget(target.id))) - getReposMock.mockClear() - resolveWorktreeExecutionHostCalls.count = 0 - - await callSetForConnectedTargets({ - hydratedTargetIds: targets.map((target) => target.id), - expectedRevisionsByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].revision]) - ), - expectedHostObservationTokensByTargetId: Object.fromEntries( - targets.map((target, index) => [target.id, observed[index].hostObservationToken]) - ) - }) - - expect(getReposMock).toHaveBeenCalledTimes(1) - // 6 worktree keys resolved once each, regardless of how many targets are published to. - expect(resolveWorktreeExecutionHostCalls.count).toBe(6) - }) - - it('skips the session and repo-catalog reads when no hydrated target is connected', async () => { - // A hydrated but disconnected target leaves nothing to project onto, so hoisting the catalog - // read must not make the idle path pay for a full repo hydration it never used before. - getActiveMultiplexerMock.mockReturnValue(undefined) - getReposMock.mockClear() - getWorkspaceSessionMock.mockClear() - - await expect( - callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { 'target-1': 'token' } - }) - ).resolves.toEqual([]) - - expect(getReposMock).not.toHaveBeenCalled() - expect(getWorkspaceSessionMock).not.toHaveBeenCalled() - }) - - it('does not write without an explicit non-empty hydrated target set', async () => { - await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1'] }) - ).resolves.toEqual([]) - await expect( - callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 } - }) - ).resolves.toEqual([]) - - expect(getSshConnectionStoreMock).not.toHaveBeenCalled() - expect(getActiveMultiplexerMock).not.toHaveBeenCalled() - }) - - it('writes only to explicitly hydrated connected targets', async () => { - const observation = await observeTarget('target-1') - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1', 'missing-target'], - expectedRevisionsByTargetId: { 'target-1': 7, 'missing-target': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken, - 'missing-target': 'unreachable-target-observation' - } - }) - - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) - expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1') - expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2') - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ kind: 'replace-session' }) - }) - ) - expect(requestByTargetId.get('target-2')).toBeUndefined() - }) - - it('can export from the persisted store session when no session argument is provided', async () => { - getWorkspaceSessionMock.mockReturnValue({ - activeRepoId: 'repo-target-1', - activeWorktreeId: 'repo-target-1::/repo', - activeTabId: 'tab-store', - tabsByWorktree: { - 'repo-target-1::/repo': [ - { - id: 'tab-store', - title: 'Store shell', - ptyId: 'pty-store', - worktreeId: 'repo-target-1::/repo' - } as never - ] - }, - terminalLayoutsByTabId: {} - }) - - const observation = await observeTarget('target-1') - await callSetForConnectedTargets({ - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': 7 }, - expectedHostObservationTokensByTargetId: { - 'target-1': observation.hostObservationToken - } - }) - - expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( - 'workspace.patch', - expect.objectContaining({ - patch: expect.objectContaining({ - session: expect.objectContaining({ - activeWorktreePath: '/repo', - activeTabId: 'tab-store' - }) - }) - }) - ) - }) - - it('does not invalidate an upload authority when an unchanged snapshot is polled', async () => { - const first = await observeTarget('target-1') - const second = await observeTarget('target-1') - expect(second.hostObservationToken).toBe(first.hostObservationToken) - - const result = await callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: ['target-1'], - expectedRevisionsByTargetId: { 'target-1': first.revision }, - expectedHostObservationTokensByTargetId: { - 'target-1': first.hostObservationToken - } - }) - - expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) - }) -}) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts deleted file mode 100644 index 43441b381bee..000000000000 --- a/src/main/ipc/remote-workspace.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { ipcMain, type BrowserWindow } from 'electron' -import type { Store } from '../persistence' -import { getActiveMultiplexer, getSshConnectionStore } from './ssh' -import { - REMOTE_WORKSPACE_CHANGED_NOTIFICATION, - REMOTE_WORKSPACE_STALE_NOTIFICATION, - type RemoteWorkspaceChangedEvent, - type RemoteWorkspaceObservedPatchResult, - type RemoteWorkspaceObservedSnapshot -} from '../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { createRepoRowExecutionHostLookup } from '../../shared/worktree-execution-host-resolution' -import { - createWorktreeOwnerResolver, - createWorktreeTargetResolver, - exportSessionForTarget, - persistedSessionForTarget -} from './remote-workspace-target-session-export' -import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' -import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' -import { CLIENT_ID, type RemoteWorkspaceClientNameSource } from './remote-workspace-client-identity' -import { listRemoteWorkspaceConnectedClients } from './remote-workspace-connected-clients' -import { - clearRemoteWorkspacePatchTails, - getRemoteWorkspacePatchTailCount, - queueRemoteWorkspacePatch -} from './remote-workspace-patch-queue' -import { getRemoteSnapshot, patchRemoteWorkspaceSession } from './remote-workspace-relay-sync' -import { - cachedRemoteWorkspaceSnapshotAuthorizesRevision, - clearRemoteWorkspaceSnapshotCache, - getCachedRemoteWorkspaceSnapshot, - getRemoteWorkspaceSnapshotCacheSize, - rememberLocallyPatchedRemoteWorkspaceSnapshot, - rememberRemoteWorkspaceSnapshot -} from './remote-workspace-snapshot-cache' -import { normalizeSnapshot } from './remote-workspace-snapshot-normalization' -import { - _resetRemoteWorkspaceStaleResyncForTests, - resyncStaleRemoteWorkspace -} from './remote-workspace-stale-resync' - -let mainWindowGetter: (() => BrowserWindow | null) | null = null -let unregisterRemoteWorkspaceNotifications: (() => void) | null = null - -export function _resetRemoteWorkspaceCachesForTests(): void { - clearRemoteWorkspaceSnapshotCache() - clearRemoteWorkspacePatchTails() - _resetRemoteWorkspaceStaleResyncForTests() -} - -export function _getRemoteWorkspaceCacheSizesForTests(): { - snapshots: number - patchTails: number -} { - return { - snapshots: getRemoteWorkspaceSnapshotCacheSize(), - patchTails: getRemoteWorkspacePatchTailCount() - } -} - -function getExplicitHydratedTargetIds(value: unknown): Set | null { - if ( - !Array.isArray(value) || - value.length === 0 || - value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0) - ) { - return null - } - return new Set(value) -} - -function getExpectedTargetRevisions( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const revisions = new Map() - for (const targetId of targetIds) { - const revision = (value as Record)[targetId] - if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) { - return null - } - revisions.set(targetId, revision) - } - return revisions -} - -function getExpectedHostObservationTokens( - value: unknown, - targetIds: ReadonlySet -): Map | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const tokens = new Map() - for (const targetId of targetIds) { - const token = (value as Record)[targetId] - if (typeof token !== 'string' || token.length === 0 || token.length > 128) { - return null - } - tokens.set(targetId, token) - } - return tokens -} - -function sendRemoteWorkspaceChanged( - targetId: string, - snapshot: RemoteWorkspaceObservedSnapshot, - sourceClientId: string | undefined -): void { - const event: RemoteWorkspaceChangedEvent = { - targetId, - snapshot, - ...(sourceClientId !== undefined ? { sourceClientId } : {}) - } - const win = mainWindowGetter?.() - if (win && !win.isDestroyed()) { - win.webContents.send('remoteWorkspace:changed', event) - } -} - -export function handleRemoteWorkspaceNotification( - targetId: string, - method: string, - params: Record -): void { - if (method === REMOTE_WORKSPACE_STALE_NOTIFICATION) { - const target = getSshConnectionStore()?.getTarget(targetId) - if (!target) { - return - } - // No sourceClientId on the resynced event: the marker names no author, and guessing one would - // let the renderer's own-echo filter discard another device's change. - void resyncStaleRemoteWorkspace(target, (snapshot) => - sendRemoteWorkspaceChanged(targetId, snapshot, undefined) - ) - return - } - if (method !== REMOTE_WORKSPACE_CHANGED_NOTIFICATION) { - return - } - const target = getSshConnectionStore()?.getTarget(targetId) - if (!target) { - return - } - const namespace = getRemoteWorkspaceNamespace(target) - const snapshot = normalizeSnapshot(params.snapshot, namespace) - const sourceClientId = - typeof params.sourceClientId === 'string' ? params.sourceClientId : undefined - const observedSnapshot = - sourceClientId === CLIENT_ID - ? rememberLocallyPatchedRemoteWorkspaceSnapshot(targetId, snapshot) - : rememberRemoteWorkspaceSnapshot(targetId, snapshot) - sendRemoteWorkspaceChanged(targetId, observedSnapshot, sourceClientId) -} - -export function registerRemoteWorkspaceHandlers( - store: Store, - getMainWindow: () => BrowserWindow | null, - clientNameSource: RemoteWorkspaceClientNameSource -): void { - mainWindowGetter = getMainWindow - unregisterRemoteWorkspaceNotifications?.() - unregisterRemoteWorkspaceNotifications = registerRemoteWorkspaceNotificationHandler( - handleRemoteWorkspaceNotification - ) - ipcMain.removeHandler('remoteWorkspace:get') - ipcMain.removeHandler('remoteWorkspace:setForConnectedTargets') - ipcMain.removeHandler('remoteWorkspace:listEnabledConnectedTargets') - ipcMain.removeHandler('remoteWorkspace:listConnectedClients') - ipcMain.removeHandler('remoteWorkspace:clientId') - - ipcMain.handle('remoteWorkspace:get', async (_event, args: { targetId: string }) => { - const target = getSshConnectionStore()?.getTarget(args.targetId) - if (!target) { - return null - } - return getRemoteSnapshot(target) - }) - - ipcMain.handle( - 'remoteWorkspace:setForConnectedTargets', - async ( - _event, - args: { - session?: WorkspaceSessionState - hydratedTargetIds?: unknown - expectedRevisionsByTargetId?: unknown - expectedHostObservationTokensByTargetId?: unknown - } - ) => { - const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) - if (!hydratedTargetIds) { - // Why: an omitted hydration set used to broadcast one session to every - // SSH target, overwriting unrelated remote workspace snapshots. - return [] - } - const expectedRevisions = getExpectedTargetRevisions( - args.expectedRevisionsByTargetId, - hydratedTargetIds - ) - if (!expectedRevisions) { - return [] - } - const expectedHostObservationTokens = getExpectedHostObservationTokens( - args.expectedHostObservationTokensByTargetId, - hydratedTargetIds - ) - if (!expectedHostObservationTokens) { - return [] - } - const targets = - getSshConnectionStore() - ?.listTargets() - .filter( - (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) - ) ?? [] - - if (targets.length === 0) { - // Nothing to project onto, so skip the session and repo-catalog reads entirely. - return [] - } - - // One repo read, and ownership resolutions shared across targets: neither depends on the - // target. The publish fallback's catalog attribution reads the same lookup for the same - // reason — building it per target re-hydrates every repo row once per connected host. - const resolveWorktreeOwner = createWorktreeOwnerResolver( - createRepoRowExecutionHostLookup(store.getRepos()) - ) - const resolveWorktreeTarget = createWorktreeTargetResolver(resolveWorktreeOwner) - const results = await Promise.all( - targets.map(async (target) => { - // Why: each target has its own revision stream. Keep same-target - // writes queued, but do not let one slow relay block others. - const session = exportSessionForTarget( - resolveWorktreeTarget, - target.id, - args.session ?? persistedSessionForTarget(store, target.id, resolveWorktreeOwner) - ) - const result = await queueRemoteWorkspacePatch(target.id, async () => { - const current = - getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) - const expectedRevision = expectedRevisions.get(target.id) - const expectedHostObservationToken = expectedHostObservationTokens.get(target.id) - if ( - !current || - expectedRevision === undefined || - expectedHostObservationToken === undefined || - current.hostObservationToken !== expectedHostObservationToken || - !cachedRemoteWorkspaceSnapshotAuthorizesRevision(target.id, expectedRevision) - ) { - const latest = getCachedRemoteWorkspaceSnapshot(target.id) ?? current - return latest - ? ({ ok: false, reason: 'stale-revision', snapshot: latest } as const) - : null - } - return patchRemoteWorkspaceSession(target, session) - }) - return result ? { targetId: target.id, result } : null - }) - ) - return results.filter( - (entry): entry is { targetId: string; result: RemoteWorkspaceObservedPatchResult } => - entry !== null - ) - } - ) - - ipcMain.handle( - 'remoteWorkspace:listEnabledConnectedTargets', - async () => - getSshConnectionStore() - ?.listTargets() - .filter((target) => getActiveMultiplexer(target.id)) - .map((target) => target.id) ?? [] - ) - - ipcMain.handle( - 'remoteWorkspace:listConnectedClients', - async (_event, args?: { targetIds?: string[] }) => - listRemoteWorkspaceConnectedClients(args, clientNameSource) - ) - - ipcMain.handle('remoteWorkspace:clientId', () => CLIENT_ID) -} diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts deleted file mode 100644 index 1ec5167f866b..000000000000 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' -/** - * What the remote-workspace export publishes when the renderer omits `session`, against the real - * `Store`. - * - * The shipping debounced writer takes that fallback on every session write, and it used to read the - * 'local' blob alone — so an SSH worktree whose tabs the main-process runtime had written to - * `ssh:` was projected as an explicit empty tab list. The upload is a - * `replace-session` patch, which turns that absence into deletion on the host (#12721, #18173). - * - * Drives the real `Store` rather than a `getWorkspaceSession` fake: the whole defect is which - * partition the read reaches, and a fake answers whatever the test tells it to. - */ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../shared/repo-types' -import type { - RemoteWorkspaceSession, - RemoteWorkspaceSnapshot -} from '../../shared/remote-workspace-types' -import type { SshTarget } from '../../shared/ssh-types' -import type { TerminalTab } from '../../shared/terminal-tab-types' - -const { getActiveMultiplexerMock, getSshConnectionStoreMock } = vi.hoisted(() => ({ - getActiveMultiplexerMock: vi.fn(), - getSshConnectionStoreMock: vi.fn() -})) - -const ipcHandlers = new Map unknown>() - -vi.mock('electron', () => ({ - app: { - getPath: () => tmpdir(), - getName: () => 'orca-test', - getVersion: () => '0.0.0-test', - isPackaged: false, - on: () => {}, - whenReady: () => Promise.resolve() - }, - safeStorage: { - isEncryptionAvailable: () => false, - encryptString: (value: string) => Buffer.from(value), - decryptString: (value: Buffer) => value.toString() - }, - ipcMain: { - on: () => {}, - handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { - ipcHandlers.set(channel, handler) - }, - removeHandler: (channel: string) => { - ipcHandlers.delete(channel) - } - }, - BrowserWindow: { getAllWindows: () => [] } -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, - getSshConnectionStore: getSshConnectionStoreMock -})) - -vi.mock('./remote-workspace-events', () => ({ - registerRemoteWorkspaceNotificationHandler: () => () => {} -})) - -const { Store } = await import('../persistence/loading-store/store') -const { getDefaultWorkspaceSession } = await import('../../shared/constants') -const { _resetRemoteWorkspaceCachesForTests, registerRemoteWorkspaceHandlers } = - await import('./remote-workspace') - -const TARGET_ID = 'target-1' -const SSH_HOST_ID = `ssh:${TARGET_ID}` as const -const REPO_ID = 'repo-remote' -const WORKTREE_PATH = '/remote/checkout/feature' -const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` - -const OTHER_TARGET_ID = 'target-2' -const OTHER_SSH_HOST_ID = `ssh:${OTHER_TARGET_ID}` as const -const OTHER_REPO_ID = 'repo-other' -const OTHER_WORKTREE_ID = `${OTHER_REPO_ID}::/elsewhere/checkout/main` - -function sshTarget(id: string, host: string): SshTarget { - return { id, label: id, host, port: 22, username: 'alice' } -} - -const target = sshTarget(TARGET_ID, 'one.example.com') -const otherTarget = sshTarget(OTHER_TARGET_ID, 'two.example.com') - -function remoteRepo(id: string, path: string, connectionId: string): Repo { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal names every Repo field this export suite reads. - return { id, path, displayName: id, badgeColor: 'blue', addedAt: 1, connectionId } as Repo -} - -function runtimeAuthoredTab(): TerminalTab { - return { - id: 'tab-runtime', - ptyId: 'pty-runtime', - worktreeId: WORKTREE_ID, - title: 'claude', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -const directories: string[] = [] -let hostSnapshot: RemoteWorkspaceSnapshot - -afterEach(async () => { - await closeTestStores() - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } - vi.restoreAllMocks() -}) - -beforeEach(() => { - _resetRemoteWorkspaceCachesForTests() - ipcHandlers.clear() - hostSnapshot = { - namespace: 'ns-target-1', - revision: 4, - updatedAt: 100, - schemaVersion: 1, - session: { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - } - } - getSshConnectionStoreMock.mockReset() - getSshConnectionStoreMock.mockReturnValue({ - listTargets: () => [target, otherTarget], - getTarget: (targetId: string) => - [target, otherTarget].find((candidate) => candidate.id === targetId) - }) - getActiveMultiplexerMock.mockReset() - getActiveMultiplexerMock.mockImplementation((targetId: string) => - targetId === TARGET_ID - ? { - request: (method: string, params: Record) => { - if (method === 'workspace.get') { - return Promise.resolve(hostSnapshot) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: params.patch crosses the IPC boundary as unknown; this suite only ever sends a session patch. - const patch = params.patch as { session: RemoteWorkspaceSession } - hostSnapshot = { - ...hostSnapshot, - revision: hostSnapshot.revision + 1, - session: patch.session - } - return Promise.resolve({ ok: true, snapshot: hostSnapshot }) - } - } - : undefined - ) -}) - -/** The observed shape from #12721: the runtime owns the tab list in `ssh:` while the - * local blob still carries the worktree key with an empty list. */ -function createStrandedStore(): InstanceType { - const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-ssh-partition-export-'))) - directories.push(dir) - const store = createSqliteTestStore(Store, { dataFile: join(dir, 'orca-data.json') }) - store.addRepo(remoteRepo(REPO_ID, '/remote/checkout', TARGET_ID)) - // A second populated SSH partition: the fallback has to reach the publishing target's own - // partition, not merely "some" partition that happens to hold tabs. - store.addRepo(remoteRepo(OTHER_REPO_ID, '/elsewhere/checkout', OTHER_TARGET_ID)) - store.setWorkspaceSession({ - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [] } - }) - store.setWorkspaceSession( - { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] } - }, - SSH_HOST_ID - ) - store.setWorkspaceSession( - { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [OTHER_WORKTREE_ID]: [ - { ...runtimeAuthoredTab(), id: 'tab-other', worktreeId: OTHER_WORKTREE_ID } - ] - } - }, - OTHER_SSH_HOST_ID - ) - return store -} - -async function publishToConnectedTarget(store: InstanceType): Promise { - registerRemoteWorkspaceHandlers(store, () => null, { readMachineName: () => 'Build server' }) - const get = ipcHandlers.get('remoteWorkspace:get') - const set = ipcHandlers.get('remoteWorkspace:setForConnectedTargets') - if (!get || !set) { - throw new Error('remote workspace handlers were never registered') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the remote-workspace get handler answers with this revision/token pair; the IPC return type is unknown. - const observed = (await get(null, { targetId: TARGET_ID })) as { - revision: number - hostObservationToken: string - } - await set(null, { - // The shipping debounced writer omits `session` and relies on the main-side fallback. - hydratedTargetIds: [TARGET_ID], - expectedRevisionsByTargetId: { [TARGET_ID]: observed.revision }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: observed.hostObservationToken } - }) -} - -describe('remoteWorkspace:setForConnectedTargets session fallback', () => { - it('publishes tabs the runtime persisted into the target ssh partition', async () => { - const store = createStrandedStore() - - await publishToConnectedTarget(store) - - expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]?.map((tab) => tab.id)).toEqual([ - 'tab-runtime' - ]) - }) - - it('never replaces the host snapshot with an empty list for a worktree that has tabs', async () => { - // The deletion step itself: `replace-session` makes an exported empty list authoritative, so - // publishing one for a populated worktree is what destroyed the host's copy on every launch. - const store = createStrandedStore() - - await publishToConnectedTarget(store) - - expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([]) - }) -}) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 5aa48ec60aa7..40c7647b9005 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import { cancelHistoryGc } from '../terminal-history-gc' import type { registerSshHandlers } from '../ipc/ssh' -import type { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import type { registerDaemonManagementHandlers } from '../ipc/pty-management' import type { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' import type { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' @@ -29,7 +28,6 @@ const { registerWorktreeHandlersMock, registerPtyHandlersMock, registerSshHandlersMock, - registerRemoteWorkspaceHandlersMock, registerDaemonManagementHandlersMock, registerWorkspaceCleanupHandlersMock, startFolderRepoGitUpgradeWatchMock, @@ -59,8 +57,6 @@ const { registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), registerSshHandlersMock: vi.fn<(...args: Parameters) => void>(), - registerRemoteWorkspaceHandlersMock: - vi.fn<(...args: Parameters) => void>(), registerDaemonManagementHandlersMock: vi.fn(), registerWorkspaceCleanupHandlersMock: vi.fn(), startFolderRepoGitUpgradeWatchMock: vi.fn(), @@ -122,9 +118,6 @@ vi.mock('../ipc/pty', () => ({ })) vi.mock('../ipc/ssh', () => ({ registerSshHandlers: registerSshHandlersMock })) -vi.mock('../ipc/remote-workspace', () => ({ - registerRemoteWorkspaceHandlers: registerRemoteWorkspaceHandlersMock -})) vi.mock('../ipc/pty-management', () => ({ registerDaemonManagementHandlers: registerDaemonManagementHandlersMock })) @@ -233,12 +226,6 @@ describe('attachMainWindowServices', () => { runtime ) expect(registerSshHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) - expect(registerRemoteWorkspaceHandlersMock).toHaveBeenCalledExactlyOnceWith( - store, - expect.any(Function), - runtime - ) - expect(registerRemoteWorkspaceHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) expect(registerDaemonManagementHandlersMock).toHaveBeenCalledExactlyOnceWith() expect(registerDaemonManagementHandlersMock).toHaveBeenCalledAfter(registerPtyHandlersMock) expect(registerWorkspaceCleanupHandlersMock).toHaveBeenCalledExactlyOnceWith(store) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4c412e9fedf7..ecbb3df6877f 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -20,7 +20,6 @@ import { } from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' -import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' @@ -125,7 +124,6 @@ export function attachMainWindowServices( void hydrateLocalPtyRegistryAtBoot(store) } registerSshHandlers(store, () => mainWindow, runtime) - registerRemoteWorkspaceHandlers(store, () => mainWindow, runtime) registerDroppedPathPreparation(mainWindow) registerTccPromptNoticeHandlers(mainWindow) scheduleMainWindowAutoUpdaterSetup(mainWindow, store, options) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cf5060fab898..00f3f4cc66d5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -124,7 +124,6 @@ export type PreloadApi = { ephemeralVm: EphemeralVmApi cache: WorkspaceSessionApi['cache'] session: WorkspaceSessionApi['session'] - remoteWorkspace: WorkspaceSessionApi['remoteWorkspace'] updater: UpdaterApi notebook: FilesystemApi['notebook'] docPreview: DocPreviewApi['docPreview'] diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts deleted file mode 100644 index 2a7ca528d211..000000000000 --- a/src/preload/api/remote-workspace-bridge.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ipcRenderer } from 'electron' -import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' -import type { PreloadApi } from '../api-types' - -export const remoteWorkspaceApi = { - get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), - listEnabledConnectedTargets: () => - ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), - listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), - clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), - onChanged: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => - callback(data) - ipcRenderer.on('remoteWorkspace:changed', listener) - return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) - } -} satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index d621e5dc7db6..68eaf4f6e009 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -5,12 +5,6 @@ import type { } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' -import type { - RemoteWorkspaceChangedEvent, - RemoteWorkspaceConnectedClient, - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../shared/remote-workspace-types' export type WorkspaceSessionApi = { session: { @@ -42,19 +36,4 @@ export type WorkspaceSessionApi = { } }) => Promise } - remoteWorkspace: { - get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> - listEnabledConnectedTargets: () => Promise - listConnectedClients: (args?: { - targetIds?: string[] - }) => Promise<{ targetId: string; clients: RemoteWorkspaceConnectedClient[] }[]> - clientId: () => Promise - onChanged: (callback: (event: RemoteWorkspaceChangedEvent) => void) => () => void - } } diff --git a/src/preload/index.ts b/src/preload/index.ts index fcccbffe7d44..783b64632fab 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,7 +54,6 @@ import { hooksApi } from './api/hooks-bridge' import { ephemeralVmApi } from './api/ephemeral-vm-bridge' import { cacheApi } from './api/cache-bridge' import { sessionApi } from './api/session-bridge' -import { remoteWorkspaceApi } from './api/remote-workspace-bridge' import { updaterApi } from './api/updater-bridge' import { docPreviewApi } from './api/doc-preview-bridge' import { notebookApi } from './api/notebook-bridge' @@ -154,7 +153,6 @@ const api = { ephemeralVm: ephemeralVmApi, cache: cacheApi, session: sessionApi, - remoteWorkspace: remoteWorkspaceApi, updater: updaterApi, docPreview: docPreviewApi, notebook: notebookApi, diff --git a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx b/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx deleted file mode 100644 index 3b65dbd380f3..000000000000 --- a/src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx +++ /dev/null @@ -1,496 +0,0 @@ -// @vitest-environment happy-dom - -/** - * The data-safety end of STA-3593. Hydrating a target authorises workspace uploads - * (`use-app-session-persistence.ts`), an upload is a `replace-session` patch - * (`remote-workspace-relay-sync.ts`), and the host applies that by wholesale replacement. So a - * client that hydrated on a snapshot whose tab rows it could not place would upload its incomplete - * projection and DELETE the host tabs it failed to adopt. - * - * That is the asymmetry the design turns on: a suppressed upload is deferred and recoverable, a - * wiped host snapshot is not. This asserts the upload itself never names an unplaced target — - * through the real persistence gate, not the hydrated flag — so it still bites if the gating moves. - */ -import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { DirectSshPreparationInput } from '../hooks/direct-ssh-reconnect-coordinator' -import { createRemoteWorkspaceTargetSync } from '../hooks/remote-workspace-target-sync' -import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' -import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -import { useAppStore } from '@/store' -import { useAppSessionPersistence } from './use-app-session-persistence' -import * as workspaceSessionHostPersistence from '../lib/workspace-session-host-persistence' - -const TARGET_ID = 'target-a' -const REPO_ROOT = '/srv/proj' -const HOST_PATH = `${REPO_ROOT}/alpha` -const WORKTREE_ID = `repo-a::${HOST_PATH}` -/** Long enough that any deferred retry or debounce would have fired if one existed. */ -const SETTLE_MS = 12_000 -/** Longer than the post-apply session-write suppression in remote-workspace-snapshot-apply.ts. */ -const WRITE_SUPPRESSION_MS = 1_500 -const DEBOUNCE_MS = 300 - -const owner: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -function snapshot( - revision = 4, - hostObservationToken = `observation-${revision}` -): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken, - session: { - activeWorktreePath: HOST_PATH, - activeTabId: 'T1', - tabsByWorktreePath: { - [HOST_PATH]: [0, 1].map((index) => ({ - id: `T${index + 1}`, - worktreePath: HOST_PATH, - ptyId: `pty-T${index + 1}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {} - } - } -} - -type UploadArgs = { - hydratedTargetIds?: string[] - expectedRevisionsByTargetId?: Record - expectedHostObservationTokensByTargetId?: Record - session?: unknown -} -type UploadResponse = { targetId: string; result: RemoteWorkspaceObservedPatchResult }[] -const uploads = vi.fn(async (_args: UploadArgs): Promise => []) - -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 } -} - -function installWindowApi(sessionPatch = vi.fn(async () => {})): void { - ;(window as unknown as { api: unknown }).api = { - session: { patch: sessionPatch, set: vi.fn(async () => {}) }, - remoteWorkspace: { setForConnectedTargets: uploads }, - app: { stageBeforeUnloadSync: vi.fn() }, - ui: { onWindowCloseRequested: vi.fn(() => () => {}) } - } -} - -function seedStore(withHostCatalog: boolean): void { - useAppStore.setState({ - ...useAppStore.getInitialState(), - workspaceSessionReady: true, - hydrationSucceeded: true, - repos: [ - { - id: 'repo-a', - path: REPO_ROOT, - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID, - executionHostId: `ssh:${TARGET_ID}` - } as never - ], - worktreesByRepo: withHostCatalog - ? { - 'repo-a': [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repo-a', - path: HOST_PATH, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - : {}, - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: {}, - tabsByWorktree: {}, - reconnectPersistedTerminals: (async () => {}) as never - }) -} - -function preparationInput( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number -): DirectSshPreparationInput { - return { - ...authority, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: `ssh:${TARGET_ID}` }], - authorityRequirement: 'required', - reason, - snapshotRevision - } -} - -function createSync( - capturePreparationInput = async ( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number - ): Promise => preparationInput(authority, reason, snapshotRevision) -) { - return createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: { - get: vi.fn(async () => snapshot()), - setForConnectedTargets: uploads as never - }, - getCurrentAuthority: () => owner, - isPreparationTokenCurrent: () => true, - capturePreparationInput, - prepareOnly: async (input) => ({ - status: 'degraded' as const, - token: { - authority: owner, - catalogRevision: 1, - repoFingerprint: 'fp', - authorityRequirement: 'required' as const, - snapshotRevision: input.snapshotRevision ?? null, - outcome: 'degraded' as const - }, - repoOutcomes: { - complete: 0, - 'non-authoritative': 1, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - }, - lineageOutcome: 'degraded' as const - }), - finalizeHydratedTerminals: () => 0 - }) -} - -// The initial full write must settle before measured upload responses are installed. -async function renderSettledPersistence() { - const pendingWrites: Promise[] = [] - const persistByHost = workspaceSessionHostPersistence.patchWorkspaceSessionByHost - const observeWrite = vi.spyOn(workspaceSessionHostPersistence, 'patchWorkspaceSessionByHost') - observeWrite.mockImplementation((...args) => { - const result = persistByHost(...args) - pendingWrites.push(result.written) - return result - }) - try { - installWindowApi() - const persistence = renderHook(() => useAppSessionPersistence()) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(pendingWrites).toHaveLength(1) - await Promise.all(pendingWrites) - expect(uploads).not.toHaveBeenCalled() - return persistence - } finally { - await Promise.allSettled(pendingWrites) - observeWrite.mockRestore() - } -} - -/** A local edit the persistence subscriber must want to write, then let it settle. */ -async function touchSessionAndSettle(marker: string): Promise { - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: marker }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) -} - -async function flushMicrotasks(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -function uploadedTargetIds(): string[] { - return uploads.mock.calls.flatMap((call) => call[0]?.hydratedTargetIds ?? []) -} - -function authorizeUploadsAtRevision(revision: number): void { - useAppStore.setState({ - remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID]), - remoteWorkspaceSyncStatusByTargetId: { - [TARGET_ID]: { - phase: 'synced', - direction: 'pull', - revision, - hostObservationToken: `observation-${revision}` - } - } - }) -} - -let nextCaseTime = Date.now() - -beforeEach(() => { - vi.useFakeTimers({ now: nextCaseTime }) - uploads.mockReset() - uploads.mockResolvedValue([]) - installWindowApi() -}) - -afterEach(async () => { - try { - cleanup() - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - expect(isDirectSshRemoteWorkspaceApplyInProgress()).toBe(false) - } finally { - // The shared apply deadline must not outlive a rollback of this fixture's clock. - nextCaseTime = Date.now() - vi.useRealTimers() - } -}) - -describe('uploads from a client that could not place the host tabs', () => { - it('cancels a captured upload when a snapshot arrives during its local write', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const pendingLocalWrite = deferred() - const sessionPatch = vi.fn(() => pendingLocalWrite.promise) - installWindowApi(sessionPatch) - const persistence = renderHook(() => useAppSessionPersistence()) - - useAppStore.setState({ activeTabId: 'before-snapshot' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalled() - - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - pendingLocalWrite.resolve() - await Promise.resolve() - await Promise.resolve() - - expect( - uploadedTargetIds(), - 'an upload captured before the incoming revision overwrote its cached tabs' - ).not.toContain(TARGET_ID) - - sync.stop() - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - persistence.unmount() - }) - - it('excludes an incoming snapshot target while preparation is pending', async () => { - seedStore(true) - authorizeUploadsAtRevision(3) - const persistence = renderHook(() => useAppSessionPersistence()) - const pendingCapture = deferred() - const sync = createSync(() => pendingCapture.promise) - - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await touchSessionAndSettle('capture-pending') - - expect( - uploadedTargetIds(), - 'the cached incoming revision was overwritten before its tabs could be applied' - ).not.toContain(TARGET_ID) - - pendingCapture.resolve(preparationInput(owner, 'workspace-snapshot', 4)) - await pendingApply - sync.stop() - persistence.unmount() - }) - - it('issues no upload for a target whose host tabs it could not place', async () => { - seedStore(false) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - // Deliberately not asserting the placement verdict first: the oracle is the upload, and a - // precondition on the verdict would fail ahead of it and hide whether the upload gate holds. - const pendingApply = sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - await vi.advanceTimersByTimeAsync(10_000) - await pendingApply - expect( - useAppStore.getState().tabsByWorktree[WORKTREE_ID], - 'the host named two terminals and this client placed neither' - ).toBeUndefined() - - // Before the client has settled: it is not authoritative for this target yet. - await touchSessionAndSettle('mid-chain') - expect( - uploadedTargetIds(), - 'a client that adopted none of the host tabs uploaded over them' - ).not.toContain(TARGET_ID) - - // Still no upload once everything has settled: not adopting is not permission to overwrite the host. - await vi.advanceTimersByTimeAsync(SETTLE_MS) - expect( - useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]?.phase, - 'a client that placed none of the host tabs reported a healthy sync' - ).not.toBe('synced') - await touchSessionAndSettle('settled') - expect( - uploadedTargetIds(), - 'settling authorised a replace-session upload built from an incomplete projection' - ).not.toContain(TARGET_ID) - - sync.stop() - persistence.unmount() - }) - - it('does upload for a target whose host tabs it did place', async () => { - seedStore(true) - const persistence = renderHook(() => useAppSessionPersistence()) - const sync = createSync() - - await sync.applyUnsolicitedSnapshot(TARGET_ID, snapshot()) - // Adoption is the observable outcome; the apply reports nothing back by design. - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ - 'T1', - 'T2' - ]) - - await touchSessionAndSettle('placed') - - // Without this the suppression assertions above would pass on a harness that never uploads. - expect(uploadedTargetIds()).toContain(TARGET_ID) - expect(uploads).toHaveBeenCalledWith( - expect.objectContaining({ - expectedRevisionsByTargetId: { [TARGET_ID]: 4 }, - expectedHostObservationTokensByTargetId: { - [TARGET_ID]: 'observation-4' - } - }) - ) - - sync.stop() - persistence.unmount() - }) - - it('keeps a later same-lineage upload after the earlier result advances the revision', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - const secondLocalWrite = deferred() - let localWriteCount = 0 - const sessionPatch = vi.fn(() => { - localWriteCount += 1 - return localWriteCount === 2 ? secondLocalWrite.promise : Promise.resolve() - }) - const firstUpload = deferred() - uploads.mockImplementationOnce(() => firstUpload.promise) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(9, 'observation-7') } - } - ]) - installWindowApi(sessionPatch) - - await vi.advanceTimersByTimeAsync(WRITE_SUPPRESSION_MS) - useAppStore.setState({ activeTabId: 'first-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(uploads).toHaveBeenCalledOnce() - - useAppStore.setState({ activeTabId: 'second-local-write' }) - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) - expect(sessionPatch).toHaveBeenCalledTimes(2) - expect(uploads).toHaveBeenCalledOnce() - - firstUpload.resolve([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - secondLocalWrite.resolve() - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(uploads.mock.calls[1][0]).toMatchObject({ - expectedRevisionsByTargetId: { [TARGET_ID]: 7 }, - expectedHostObservationTokensByTargetId: { [TARGET_ID]: 'observation-7' } - }) - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 9, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) - - it('retains transient upload authority so the next local edit retries', async () => { - seedStore(true) - const persistence = await renderSettledPersistence() - authorizeUploadsAtRevision(7) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: false, reason: 'unavailable', message: 'temporary relay failure' } - } - ]) - uploads.mockResolvedValueOnce([ - { - targetId: TARGET_ID, - result: { ok: true, snapshot: snapshot(8, 'observation-7') } - } - ]) - - await touchSessionAndSettle('transient-failure') - await flushMicrotasks() - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'offline', - revision: 7, - hostObservationToken: 'observation-7' - }) - - await touchSessionAndSettle('retry-after-transient-failure') - await flushMicrotasks() - expect(uploads).toHaveBeenCalledTimes(2) - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'synced', - revision: 8, - hostObservationToken: 'observation-7' - }) - - persistence.unmount() - }) -}) diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6574438265de..5b2235a39447 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -1,10 +1,5 @@ import { useEffect } from 'react' import { useAppStore } from '../store' -import { - isDirectSshRemoteWorkspaceApplyInProgress, - onDirectSshRemoteWorkspaceApplyWindowClosed -} from '../hooks/remote-workspace-snapshot-apply' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -37,84 +32,13 @@ import { ORCA_RENDERER_SHUTDOWN_CHECKPOINT_ABORTED_EVENT, ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' -import type { AppState } from '../store/types' -import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' -import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 -type RemoteWorkspaceUploadAuthority = { - targetId: string - revision: number - updatedAt?: number - hostObservationToken: string -} - -function captureRemoteWorkspaceUploadAuthorities( - state: AppState -): RemoteWorkspaceUploadAuthority[] { - return Array.from(state.remoteWorkspaceHydratedTargetIds).flatMap((targetId) => { - const syncStatus = state.remoteWorkspaceSyncStatusByTargetId[targetId] - const revision = syncStatus?.revision - const hostObservationToken = syncStatus?.hostObservationToken - if ( - syncStatus?.phase === 'conflict' || - typeof revision !== 'number' || - !Number.isSafeInteger(revision) || - revision < 0 || - typeof hostObservationToken !== 'string' || - hostObservationToken.length === 0 - ) { - return [] - } - return [ - { - targetId, - revision, - updatedAt: syncStatus.updatedAt, - hostObservationToken - } - ] - }) -} - -function remoteWorkspaceUploadAuthorityIsCurrent( - state: AppState, - authority: RemoteWorkspaceUploadAuthority -): boolean { - const status = state.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - return ( - state.remoteWorkspaceHydratedTargetIds.has(authority.targetId) && - status?.phase !== 'conflict' && - status?.hostObservationToken === authority.hostObservationToken - ) -} - -function captureUploadedDirectSshLayoutEdits( - pendingLayoutEdits: AppState['pendingDirectSshLayoutEditsByTabId'], - targetId: string, - result: RemoteWorkspaceObservedPatchResult | undefined -): Record { - if (!result?.ok) { - return {} - } - return Object.fromEntries( - Object.entries(pendingLayoutEdits).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) -} - /** - * Writes durable renderer session state to disk: the debounced per-host writer, the remote - * workspace upload chain, and the synchronous shutdown checkpoint. + * Writes durable renderer session state to disk: the debounced per-host writer and the synchronous + * shutdown checkpoint. */ export function useAppSessionPersistence(): void { useEffect(() => registerUpdaterBeforeUnloadBypass(), []) @@ -123,81 +47,11 @@ export function useAppSessionPersistence(): void { useEffect(() => { return createSessionWriteSubscriber({ store: useAppStore, - shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), - subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, onPersistError: (error) => console.warn('[session] Session patch failed:', error), persist: ({ patch }) => { - const state = useAppStore.getState() - // Why: route each host's worktree-scoped slice to its own partition; the upload chain below orders after the local write. - const { localWrite, written } = patchWorkspaceSessionByHost( - window.api.session, - patch, - state - ) - const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) - const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId - if (uploadAuthorities.length > 0) { - void (async () => { - try { - await localWrite - const currentState = useAppStore.getState() - const currentAuthorities = uploadAuthorities.filter((authority) => - remoteWorkspaceUploadAuthorityIsCurrent(currentState, authority) - ) - if (currentAuthorities.length === 0) { - return - } - const hydratedTargetIds = currentAuthorities.map(({ targetId }) => targetId) - const expectedRevisionsByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, revision }) => [targetId, revision]) - ) - const expectedHostObservationTokensByTargetId = Object.fromEntries( - currentAuthorities.map(({ targetId, hostObservationToken }) => [ - targetId, - hostObservationToken - ]) - ) - const results = await window.api.remoteWorkspace?.setForConnectedTargets({ - hydratedTargetIds, - expectedRevisionsByTargetId, - expectedHostObservationTokensByTargetId - }) - const resultState = useAppStore.getState() - const currentAuthorityByTargetId = new Map( - currentAuthorities.map((authority) => [authority.targetId, authority]) - ) - for (const { targetId, result } of results ?? []) { - const authority = currentAuthorityByTargetId.get(targetId) - if (authority && remoteWorkspaceUploadAuthorityIsCurrent(resultState, authority)) { - if (result?.ok) { - resultState.acknowledgeDirectSshLayoutEdits( - captureUploadedDirectSshLayoutEdits(pendingLayoutEdits, targetId, result) - ) - } - applyRemoteWorkspacePushStatus(resultState, targetId, result, authority) - } - } - } catch (err) { - const errorState = useAppStore.getState() - for (const authority of uploadAuthorities) { - if (!remoteWorkspaceUploadAuthorityIsCurrent(errorState, authority)) { - continue - } - const currentStatus = - errorState.remoteWorkspaceSyncStatusByTargetId[authority.targetId] - errorState.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - direction: 'push', - revision: currentStatus?.revision ?? authority.revision, - updatedAt: currentStatus?.updatedAt ?? authority.updatedAt, - hostObservationToken: authority.hostObservationToken, - message: err instanceof Error ? err.message : 'Workspace upload failed' - }) - } - } - })() - } - return written + // Why: route each host's worktree-scoped slice to its own partition. + return patchWorkspaceSessionByHost(window.api.session, patch, useAppStore.getState()) + .written } }) }, []) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index dc8d023eec7b..fa1153418a4f 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react' -import { AlertTriangle, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' +import { Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react' import { toast } from 'sonner' import { DropdownMenu, @@ -18,11 +18,7 @@ import { } from '../../../../shared/execution-host' import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments' import { RuntimeHostStatusRow } from './RuntimeHostStatusRow' -import { - connectedHostCountLabel, - connectingHostsLabel, - workspaceSyncProblemLabel -} from './ssh-status-segment-copy' +import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy' import { SshTargetStatusRow } from './SshTargetStatusRow' import { connectRuntimeHostAndReloadProjects } from './runtime-environment-explicit-connect' import { @@ -51,9 +47,6 @@ export function SshStatusSegment({ const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses) - const remoteWorkspaceSyncStatusByTargetId = useAppStore( - (s) => s.remoteWorkspaceSyncStatusByTargetId - ) const setActiveView = useAppStore((s) => s.setActiveView) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) @@ -68,8 +61,7 @@ export function SshStatusSegment({ return { id, label, - status: (state?.status ?? 'disconnected') as SshConnectionStatus, - syncStatus: remoteWorkspaceSyncStatusByTargetId[id] + status: state?.status ?? ('disconnected' satisfies SshConnectionStatus) } }) const runtimeHosts = runtimeEnvironments @@ -139,12 +131,6 @@ export function SshStatusSegment({ const overall = overallStatus(statuses) const connectedHostCount = statuses.filter((status) => status === 'connected').length const anyConnecting = overall === 'connecting' - const syncProblem = targets.find( - (t) => t.syncStatus?.phase === 'conflict' || t.syncStatus?.phase === 'error' - ) - const syncProblemLabel = syncProblem - ? workspaceSyncProblemLabel(syncProblem.syncStatus?.phase) - : null return ( { @@ -166,13 +152,9 @@ export function SshStatusSegment({ {iconOnly ? ( - {syncProblem ? ( - - ) : anyConnecting ? ( + {anyConnecting ? ( ) : ( @@ -180,9 +162,7 @@ export function SshStatusSegment({ ) : ( - {syncProblem ? ( - - ) : anyConnecting ? ( + {anyConnecting ? ( ) : overall === 'connected' ? ( @@ -193,18 +173,15 @@ export function SshStatusSegment({ )} {!compact && ( - - {syncProblemLabel ?? - (anyConnecting - ? connectingHostsLabel() - : connectedHostCountLabel(connectedHostCount))} + + {anyConnecting + ? connectingHostsLabel() + : connectedHostCountLabel(connectedHostCount)} )} )} @@ -231,13 +208,7 @@ export function SshStatusSegment({ /> ))} {connectedTargets.map((t) => ( - + ))} {inactiveRuntimeHosts.map((host) => ( ))} {disconnectedTargets.map((t) => ( - + ))} { if (isSshConnectInFlight(targetId)) { @@ -126,23 +86,6 @@ export function SshTargetStatusRow({ {STATUS_LABELS[status]} - {visibleSyncStatusLabel ? ( - <> - - - {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( - - ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( - - ) : ( - - )} - {visibleSyncStatusLabel} - - - ) : null} {busy || connectInFlight ? ( diff --git a/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts b/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts index 3cd3957872f8..1260cbe3ccd0 100644 --- a/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts +++ b/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts @@ -5,11 +5,7 @@ vi.mock('@/i18n/i18n', () => ({ fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => String(options?.[name] ?? match)) })) -import { - connectedHostCountLabel, - connectingHostsLabel, - workspaceSyncProblemLabel -} from './ssh-status-segment-copy' +import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy' describe('ssh status segment copy', () => { it('keeps the host noun singular for a lone connected host', () => { @@ -18,9 +14,7 @@ describe('ssh status segment copy', () => { expect(connectedHostCountLabel(4)).toBe('4 hosts') }) - it('distinguishes a sync conflict from a sync failure', () => { + it('labels hosts still connecting', () => { expect(connectingHostsLabel()).toBe('Connecting…') - expect(workspaceSyncProblemLabel('conflict')).toBe('Workspace conflict') - expect(workspaceSyncProblemLabel('error')).toBe('Workspace sync error') }) }) diff --git a/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts b/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts index cf4cd7e35522..b126dfd27b4c 100644 --- a/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts +++ b/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts @@ -17,15 +17,3 @@ export function connectedHostCountLabel(count: number): string { export function connectingHostsLabel(): string { return translate('auto.components.status.bar.SshStatusSegment.connecting', 'Connecting…') } - -export function workspaceSyncProblemLabel(phase: string | undefined): string { - return phase === 'conflict' - ? translate( - 'auto.components.status.bar.SshStatusSegment.workspaceConflict', - 'Workspace conflict' - ) - : translate( - 'auto.components.status.bar.SshStatusSegment.workspaceSyncError', - 'Workspace sync error' - ) -} diff --git a/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx b/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx index 96da506d9e9f..459e9b5b5663 100644 --- a/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx +++ b/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx @@ -21,13 +21,11 @@ import { SshStatusSegment } from './SshStatusSegment' type StoreState = { sshConnectionStates: Map sshTargetLabels: Map - remoteWorkspaceSyncStatusByTargetId: Record } let storeState: StoreState = { sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - remoteWorkspaceSyncStatusByTargetId: {} + sshTargetLabels: new Map() } vi.mock('../../store', () => { @@ -52,14 +50,11 @@ vi.mock('../../store', () => { }) function setSshTargets( - entries: { id: string; label: string; status: SshConnectionStatus; syncPhase?: string }[] + entries: { id: string; label: string; status: SshConnectionStatus }[] ): void { storeState = { sshConnectionStates: new Map(entries.map((entry) => [entry.id, { status: entry.status }])), - sshTargetLabels: new Map(entries.map((entry) => [entry.id, entry.label])), - remoteWorkspaceSyncStatusByTargetId: Object.fromEntries( - entries.flatMap((entry) => (entry.syncPhase ? [[entry.id, { phase: entry.syncPhase }]] : [])) - ) + sshTargetLabels: new Map(entries.map((entry) => [entry.id, entry.label])) } } @@ -162,22 +157,9 @@ describe('status-bar copy under a non-English UI language', () => { expect(triggerText()).toContain('1 台のホスト') }) - it('translates the connecting and workspace-sync states', () => { + it('translates the connecting state', () => { setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connecting' }]) render() expect(triggerText()).toContain('接続中…') - cleanup() - - setSshTargets([ - { id: 'ssh-1', label: 'builder', status: 'connected', syncPhase: 'conflict' }, - { id: 'ssh-2', label: 'openclaw', status: 'connected', syncPhase: 'error' } - ]) - render() - expect(triggerText()).toContain('ワークスペースの競合') - cleanup() - - setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connected', syncPhase: 'error' }]) - render() - expect(triggerText()).toContain('ワークスペースの同期エラー') }) }) diff --git a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts deleted file mode 100644 index adc0d3492a4e..000000000000 --- a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { vi } from 'vitest' -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' -import type { AppState } from '../../store/types' -import type { - DirectSshPreparationInput, - DirectSshPreparationToken -} from '../direct-ssh-reconnect-coordinator' -import { createRemoteWorkspaceTargetSync } from '../remote-workspace-target-sync' - -export type Deferred = { - promise: Promise - resolve: (value: T) => void -} - -export function deferred(): Deferred { - let resolve!: (value: T) => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -export const owner: DirectSshAuthority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -export function token( - snapshotRevision: number | null = null, - catalogRevision = 1 -): DirectSshPreparationToken { - return { - authority: owner, - catalogRevision, - repoFingerprint: JSON.stringify([['ssh:target-a', 'repo-a']]), - authorityRequirement: 'required', - snapshotRevision, - outcome: 'complete' - } -} - -export function snapshot( - revision: number, - tabsByWorktreePath: RemoteWorkspaceObservedSnapshot['session']['tabsByWorktreePath'] = {} -): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken: `observation-${revision}`, - session: { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath, - terminalLayoutsByTabId: {} - } - } -} - -export function repo(id = 'repo-a') { - return { - id, - path: `/remote/${id}`, - projectGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - } -} - -export function worktree(id = 'repo-a::/remote/work') { - return { - id, - repoId: id.slice(0, id.indexOf('::')), - hostId: 'ssh:target-a' - } -} - -export function appState(overrides: Record = {}): AppState { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture intentionally supplies only the slices used by target-sync tests. - 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: {}, - pendingDirectSshLayoutEditsByTabId: {}, - activeTabIdByWorktree: {}, - openFiles: [], - editorDrafts: {}, - markdownFrontmatterVisible: {}, - activeFileIdByWorktree: {}, - activeTabTypeByWorktree: {}, - browserTabsByWorktree: {}, - browserPagesByWorkspace: {}, - activeBrowserTabIdByWorktree: {}, - browserUrlHistory: [], - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - layoutByWorktree: {}, - activeGroupIdByWorktree: {}, - remoteWorkspaceSyncStatusByTargetId: {}, - sshConnectionStates: new Map(), - lastVisitedAtByWorktreeId: {}, - defaultTerminalTabsAppliedByWorktreeId: {}, - hydrateWorkspaceSession: vi.fn(), - hydrateTabsSession: vi.fn(), - hydrateEditorSession: vi.fn(), - hydrateBrowserSession: vi.fn(), - remoteWorkspaceHydratedTargetIds: new Set(), - markRemoteWorkspaceHydrated: vi.fn(), - clearRemoteWorkspaceHydrated: vi.fn(), - setRemoteWorkspaceSyncStatus: vi.fn(), - acknowledgeDirectSshLayoutEdits: vi.fn(), - reconnectPersistedTerminals: vi.fn(async () => {}), - ...overrides - } as unknown as AppState -} - -export function createHarness( - state: AppState, - get: (args: { targetId: string }) => Promise, - patchResult: RemoteWorkspaceObservedPatchResult = { ok: true, snapshot: snapshot(1) } -) { - const setForConnectedTargets = vi.fn(async () => [ - { - targetId: owner.targetId, - result: patchResult - } - ]) - let current = true - let catalogRevision = 1 - const stateListeners = new Set<(current: AppState, previous: AppState) => void>() - let peakStateListenerCount = 0 - const publishState = (): void => { - for (const listener of stateListeners) { - listener(state, state) - } - } - const capturePreparationInput = vi.fn( - async ( - authority: DirectSshAuthority, - reason: 'workspace-snapshot', - snapshotRevision: number - ): Promise => ({ - ...authority, - catalogRevision, - 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, input.catalogRevision), - 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, - subscribe: (listener) => { - stateListeners.add(listener) - peakStateListenerCount = Math.max(peakStateListenerCount, stateListeners.size) - return () => stateListeners.delete(listener) - } - }, - remoteWorkspace: { get, setForConnectedTargets }, - getCurrentAuthority: () => (current ? owner : null), - isPreparationTokenCurrent: (candidate) => - current && candidate.catalogRevision === catalogRevision, - capturePreparationInput, - prepareOnly, - finalizeHydratedTerminals - }) - return { - sync, - setForConnectedTargets, - publishState, - capturePreparationInput, - prepareOnly, - finalizeHydratedTerminals, - activeStateListenerCount: () => stateListeners.size, - peakStateListenerCount: () => peakStateListenerCount, - advanceCatalog: () => { - catalogRevision += 1 - }, - makeStale: () => { - current = false - } - } -} - -export async function flush(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts index 31dfbe5a8ea9..5c3d5b5436bf 100644 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts @@ -40,10 +40,6 @@ export type DirectSshPreparationToken = { outcome: 'complete' | 'degraded' } -export type DirectSshSnapshotApplyToken = DirectSshPreparationToken & { - snapshotRevision: number -} - export type DirectSshLineageOutcome = 'complete' | 'degraded' | 'canceled' | 'stale' export type DirectSshRepoOutcomeCounts = Record @@ -125,12 +121,10 @@ export type DirectSshReconnectCoordinatorDeps = { 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 @@ -141,7 +135,6 @@ export type DirectSshReconnectCoordinatorDeps = { export type DirectSshReconnectCoordinator = { requestReconnect: (authority: DirectSshAuthority) => Promise prepareOnly: (input: DirectSshPreparationInput) => Promise - finalizeHydratedTerminals: (authority: DirectSshAuthority) => number correctUnboundTerminals: ( authority: DirectSshAuthority, reason: DirectSshCorrectionReason diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts index e2f007e43b7d..7d149a20c58d 100644 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts @@ -13,8 +13,6 @@ import { type WaiterLeaseId } from './direct-ssh-worktree-refresh-scheduler' import { - admitDirectSshSnapshotApplyToken, - buildDirectSshSnapshotApplyToken, createDirectSshReconnectCoordinator, type DirectSshLineageOutcome, type DirectSshPreparationInput, @@ -145,9 +143,7 @@ function createCoordinatorHarness(fakeScheduler = createFakeScheduler()) { 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) @@ -205,7 +201,7 @@ describe('createDirectSshReconnectCoordinator', () => { harness.complete(1) await expect(pendingB).resolves.toMatchObject({ status: 'complete' }) - expect(harness.deps.syncRemoteWorkspaceAfterConnect).toHaveBeenCalledTimes(1) + expect(harness.deps.correctUnboundTerminalPanes).toHaveBeenCalledTimes(1) let targetASettled = false void pendingA.then(() => { @@ -274,7 +270,7 @@ describe('createDirectSshReconnectCoordinator', () => { lineage.resolve('complete') await flush() - expect(harness.deps.syncRemoteWorkspaceAfterConnect).not.toHaveBeenCalled() + expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() }) it('joins exact overlapping preparation and reruns after completion', async () => { @@ -300,7 +296,7 @@ describe('createDirectSshReconnectCoordinator', () => { harness.coordinator.stop() }) - it('keeps prepare-only isolated from terminal retry and reconnect sync', async () => { + it('keeps prepare-only isolated from terminal retry and correction', async () => { const harness = createCoordinatorHarness() const owner = authority('target-a') harness.current.set(owner.targetId, owner) @@ -315,7 +311,6 @@ describe('createDirectSshReconnectCoordinator', () => { 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 () => { @@ -414,24 +409,6 @@ describe('createDirectSshReconnectCoordinator', () => { 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) @@ -487,7 +464,6 @@ describe('createDirectSshReconnectCoordinator', () => { 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) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts index 01474789f1c8..e46f4063c410 100644 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts +++ b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts @@ -29,10 +29,6 @@ import { 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 @@ -106,14 +102,6 @@ export function createDirectSshReconnectCoordinator( 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, @@ -146,9 +134,6 @@ export function createDirectSshReconnectCoordinator( let correctedTerminals = 0 if (prepared.token && isCurrent(authority)) { correctedTerminals = deps.correctUnboundTerminalPanes(authority, 'preparation-complete') - if (isCurrent(authority)) { - startSync(prepared.token) - } } const outcome = combineDirectSshReconnectOutcome( prepared, @@ -265,9 +250,6 @@ export function createDirectSshReconnectCoordinator( return acquired.promise } - const finalizeHydratedTerminals = (authority: DirectSshAuthority): number => - isCurrent(authority) ? deps.finalizeHydratedTerminalPanes(authority) : 0 - const correctUnboundTerminals = ( authority: DirectSshAuthority, reason: DirectSshCorrectionReason @@ -301,7 +283,6 @@ export function createDirectSshReconnectCoordinator( return { requestReconnect, prepareOnly, - finalizeHydratedTerminals, correctUnboundTerminals, replaceAuthority, invalidate, diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts index f96272684258..58e666a457ab 100644 --- a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts +++ b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts @@ -1,10 +1,6 @@ import { toSshExecutionHostId } from '../../../shared/execution-host' import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DirectSshPreparationInput, - DirectSshPreparationToken, - DirectSshSnapshotApplyToken -} from './direct-ssh-reconnect-coordinator-types' +import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator-types' export function directSshAuthoritiesEqual( left: DirectSshAuthority | null | undefined, @@ -60,24 +56,3 @@ export function directSshPreparationOperationKey(input: DirectSshPreparationInpu 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-state-routing.test.ts b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts index dbf7eb951b9d..e59014adb2ea 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts @@ -25,7 +25,7 @@ function harness() { }, invalidateStaleTerminalBindings: vi.fn(() => 0), retryTargetPanes: vi.fn(() => 0), - prepareAndSync: vi.fn(), + prepareHost: vi.fn(), rememberReconnectAuthority: vi.fn() } } @@ -42,7 +42,7 @@ describe('routeDirectSshConnectedState', () => { origin: 'initial-hydration' }) ).toBe('initial-hydration') - expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'initial-hydration') + expect(deps.prepareHost).toHaveBeenCalledWith(current, 'initial-hydration') expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() expect(deps.coordinator.correctUnboundTerminals).not.toHaveBeenCalled() }) @@ -62,7 +62,7 @@ describe('routeDirectSshConnectedState', () => { expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) expect(deps.coordinator.requestReconnect).toHaveBeenCalledWith(current) expect(deps.coordinator.replaceAuthority).not.toHaveBeenCalled() - expect(deps.prepareAndSync).not.toHaveBeenCalled() + expect(deps.prepareHost).not.toHaveBeenCalled() }) it('runs the disabled fallback terminal actions synchronously before bounded preparation', () => { @@ -78,7 +78,7 @@ describe('routeDirectSshConnectedState', () => { order.push('retry') return 1 }) - deps.prepareAndSync.mockImplementation(() => { + deps.prepareHost.mockImplementation(() => { order.push('prepare') return new Promise(() => {}) }) @@ -95,7 +95,7 @@ describe('routeDirectSshConnectedState', () => { expect(order).toEqual(['replace', 'invalidate', 'retry', 'prepare']) expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'reconnect', { + expect(deps.prepareHost).toHaveBeenCalledWith(current, 'reconnect', { authorityAlreadyReplaced: true }) expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) @@ -113,7 +113,7 @@ describe('routeDirectSshConnectedState', () => { }) ).toBe('same-authority-wake') expect(deps.coordinator.correctUnboundTerminals).toHaveBeenCalledWith(current, 'wake-refresh') - expect(deps.prepareAndSync).toHaveBeenCalledWith(current, 'wake-refresh') + expect(deps.prepareHost).toHaveBeenCalledWith(current, 'wake-refresh') expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.ts b/src/renderer/src/hooks/direct-ssh-state-routing.ts index 7b3f263a5a74..b9ae9f4f607a 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.ts @@ -12,7 +12,7 @@ export type DirectSshConnectedStateRoutingDeps = { coordinatorRoutingEnabled: boolean invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number retryTargetPanes: (authority: DirectSshAuthority) => number - prepareAndSync: ( + prepareHost: ( authority: DirectSshAuthority, reason: 'reconnect' | 'initial-hydration' | 'wake-refresh', options?: { authorityAlreadyReplaced?: boolean } @@ -37,7 +37,7 @@ export function routeDirectSshConnectedState( const { authority } = input if (input.origin === 'initial-hydration') { deps.rememberReconnectAuthority(null) - void deps.prepareAndSync(authority, 'initial-hydration') + void deps.prepareHost(authority, 'initial-hydration') return 'initial-hydration' } if (!directSshAuthoritiesEqual(input.previousAuthority, authority)) { @@ -49,11 +49,11 @@ export function routeDirectSshConnectedState( deps.coordinator.replaceAuthority(authority) deps.invalidateStaleTerminalBindings(authority) deps.retryTargetPanes(authority) - void deps.prepareAndSync(authority, 'reconnect', { authorityAlreadyReplaced: true }) + void deps.prepareHost(authority, 'reconnect', { authorityAlreadyReplaced: true }) return 'changed-authority-fallback' } deps.coordinator.correctUnboundTerminals(authority, 'wake-refresh') - void deps.prepareAndSync(authority, 'wake-refresh') + void deps.prepareHost(authority, 'wake-refresh') return 'same-authority-wake' } diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index 4aa1539be6ce..ac222130598c 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -18,7 +18,6 @@ export function buildWindowApi(args: { getSnapshot?: () => Promise getMigrationUnsupportedSnapshot?: () => Promise drop?: (paneKey: string) => void - remoteWorkspace?: Record runtime?: Record ssh?: Record ui?: Record @@ -143,8 +142,7 @@ export function buildWindowApi(args: { ? { getMigrationUnsupportedSnapshot: args.getMigrationUnsupportedSnapshot } : {}), drop: args.drop ?? vi.fn() - }, - remoteWorkspace: args.remoteWorkspace + } } } } diff --git a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts index 9490b19a4e86..79044d84f431 100644 --- a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts @@ -8,7 +8,6 @@ export type DirectSshReconnectCoordinatorDouble = { replaceAuthority: ReturnType prepareOnly: ReturnType correctUnboundTerminals: ReturnType - finalizeHydratedTerminals: ReturnType invalidate: ReturnType stop: ReturnType } @@ -50,7 +49,6 @@ export function buildSshAuthorityReconciliationHarness(args: { retryDirectSshTargetPanes: vi.fn(() => 0), setSshTargetsMetadata: vi.fn(), setRemovedSshTargetLabels: vi.fn(), - setRemoteWorkspaceSyncStatus: vi.fn(), clearRemoteDetectedAgents: vi.fn(), clearDirectSshTargetPtyBindings: vi.fn(), clearRemovedSshTargetState: vi.fn() @@ -60,7 +58,6 @@ export function buildSshAuthorityReconciliationHarness(args: { replaceAuthority: vi.fn(), prepareOnly: vi.fn(), correctUnboundTerminals: vi.fn(() => 0), - finalizeHydratedTerminals: vi.fn(() => 0), invalidate: vi.fn(), stop: vi.fn() } diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 44590fc6b5f4..2a172bc654fd 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -241,8 +241,7 @@ export async function loadIpcEventsHarness( }), mobile: createApiNamespaceStub({ consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false) - }), - remoteWorkspace: createApiNamespaceStub({ clientId: () => Promise.resolve(null) }) + }) } as Record, { get: (target, prop: string) => target[prop] ?? createApiNamespaceStub() } ) diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 4a9905c0285e..54f88778d43a 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -19,7 +19,6 @@ import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge' import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' -import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge' import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge' import { registerSessionTabIpcBridge } from './session-tab-ipc-bridge' import { registerSettingsAndSidebarIpcBridge } from './settings-sidebar-ipc-bridge' @@ -130,7 +129,6 @@ export function installAppLifetimeIpcEvents( registerTabLifecycleIpcBridge(unsubs) registerRateLimitIpcBridge(unsubs) registerDirectSshStateIpcBridge(unsubs, directSshRuntime) - registerRemoteWorkspaceIpcBridge(unsubs, directSshRuntime) registerZoomIpcBridge(unsubs) const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) const disposeMobileDriverHydration = registerMobileDriverIpcBridge( diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts index 3844d32ec0cd..3b43888b8896 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts @@ -15,10 +15,6 @@ import { } from '../direct-ssh-reconnect-coordinator' import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' import { createDirectSshWorktreeRefreshScheduler } from '../direct-ssh-worktree-refresh-scheduler' -import { - createRemoteWorkspaceTargetSync, - type RemoteWorkspaceTargetSync -} from '../remote-workspace-target-sync' import type { AppState } from '../../store/types' type DirectSshTerminalActions = Partial< @@ -30,10 +26,9 @@ export type DirectSshBridgeRuntime = { reconnectAuthorityByTarget: Map reconnectCoordinator: DirectSshReconnectCoordinator hostHydration: DirectSshHostHydration - remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null currentAuthority: (targetId: string) => DirectSshAuthority | null terminalActions: () => DirectSshTerminalActions - prepareAndSync: ( + prepareHost: ( authority: DirectSshAuthority, reason: DirectSshPreparationReason, options?: { authorityAlreadyReplaced?: boolean } @@ -110,7 +105,6 @@ export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { }) const terminalActions = (): DirectSshTerminalActions => useAppStore.getState() as DirectSshTerminalActions - let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null const reconnectCoordinator = createDirectSshReconnectCoordinator({ scheduler, isCurrentConnectedAuthority: (authority) => @@ -120,34 +114,11 @@ export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { invalidateStaleTerminalBindings: (authority) => terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, retryTargetPanes: (authority) => terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - finalizeHydratedTerminalPanes: (authority) => - terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, correctUnboundTerminalPanes: (authority) => terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - syncRemoteWorkspaceAfterConnect: (token) => remoteWorkspaceTargetSync?.syncAfterConnect(token), onTelemetry: createDirectSshReconnectProductTelemetryAdapter() }) - const remoteWorkspaceApi = window.api.remoteWorkspace - if (remoteWorkspaceApi) { - remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: remoteWorkspaceApi, - getCurrentAuthority: currentAuthority, - isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, - capturePreparationInput: (authority, reason, revision) => - hostHydration.capturePreparationInput(authority, reason, revision), - prepareOnly: reconnectCoordinator.prepareOnly, - finalizeHydratedTerminals: (authority) => - directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) - ? reconnectCoordinator.finalizeHydratedTerminals(authority) - : 0 - }) - } - const prepareAndSync: DirectSshBridgeRuntime['prepareAndSync'] = async ( - authority, - reason, - options - ) => { + const prepareHost: DirectSshBridgeRuntime['prepareHost'] = async (authority, reason, options) => { try { if (!options?.authorityAlreadyReplaced) { reconnectCoordinator.replaceAuthority(authority) @@ -156,30 +127,20 @@ export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { 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(currentAuthority(authority.targetId), authority)) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - message: error instanceof Error ? error.message : 'Workspace sync failed' - }) + if (input) { + await reconnectCoordinator.prepareOnly(input) } + } catch { + // A failed preparation leaves the previous host picture; the next connect or wake retries. } } return { reconnectAuthorityByTarget, reconnectCoordinator, hostHydration, - remoteWorkspaceTargetSync, currentAuthority, terminalActions, - prepareAndSync, + prepareHost, isStopped: () => stopped, addDeadline: (deadline) => deadlines.add(deadline), removeDeadline: (deadline) => deadlines.delete(deadline), @@ -190,7 +151,6 @@ export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { deadline.settle() } deadlines.clear() - remoteWorkspaceTargetSync?.stop() hostHydration.stop() reconnectCoordinator.stop() reconnectAuthorityByTarget.clear() diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts index 85023d75f4f5..6a7b216a1c98 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts @@ -86,7 +86,7 @@ async function createHarness( const clearBindings = vi.spyOn(store, 'clearDirectSshTargetPtyBindings').mockReturnValue(1) const runtime = createDirectSshBridgeRuntime() const requestReconnect = vi.spyOn(runtime.reconnectCoordinator, 'requestReconnect') - const prepareAndSync = vi.spyOn(runtime, 'prepareAndSync') + const prepareHost = vi.spyOn(runtime, 'prepareHost') const unsubs: (() => void)[] = [] cleanups.push(() => { for (const unsubscribe of unsubs) { @@ -103,7 +103,7 @@ async function createHarness( retry, clearBindings, requestReconnect, - prepareAndSync, + prepareHost, emit: (state: SshConnectionState) => { if (!stateListener) { throw new Error('SSH state listener was not registered') @@ -158,7 +158,7 @@ describe('direct SSH connect reply routing', () => { expect(harness.requestReconnect).toHaveBeenCalledOnce() expect(harness.invalidate).toHaveBeenCalledOnce() expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.prepareAndSync).toHaveBeenLastCalledWith(authority(state), 'wake-refresh') + expect(harness.prepareHost).toHaveBeenLastCalledWith(authority(state), 'wake-refresh') } ) @@ -166,7 +166,7 @@ describe('direct SSH connect reply routing', () => { const state = connectedState() const harness = await createHarness([state]) - expect(harness.prepareAndSync).toHaveBeenCalledExactlyOnceWith( + expect(harness.prepareHost).toHaveBeenCalledExactlyOnceWith( authority(state), 'initial-hydration' ) @@ -217,7 +217,7 @@ describe('direct SSH connect reply routing', () => { const harness = await createHarness([partial], [state]) expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(state) - expect(harness.prepareAndSync).toHaveBeenCalledExactlyOnceWith( + expect(harness.prepareHost).toHaveBeenCalledExactlyOnceWith( authority(state), 'initial-hydration' ) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts index e63ce38d2fa1..41559141dafd 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts @@ -9,6 +9,7 @@ function connectingState(targetId: string): SshConnectionState { } function bridgeRuntime(): DirectSshBridgeRuntime { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routing under test reads only these members; the rest of the runtime is never touched. return { reconnectAuthorityByTarget: new Map(), reconnectCoordinator: { @@ -19,7 +20,7 @@ function bridgeRuntime(): DirectSshBridgeRuntime { }, currentAuthority: () => null, terminalActions: () => ({}), - prepareAndSync: vi.fn(async () => {}), + prepareHost: vi.fn(async () => {}), isStopped: () => false, addDeadline: vi.fn(), removeDeadline: vi.fn(), diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts index 3e6269f53a6c..d2031965bbf3 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts @@ -44,6 +44,7 @@ function stubWindow(ssh: SshApiStub): void { } function bridgeRuntime(): DirectSshBridgeRuntime { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routing under test reads only these members; the rest of the runtime is never touched. return { reconnectAuthorityByTarget: new Map(), reconnectCoordinator: { @@ -54,7 +55,7 @@ function bridgeRuntime(): DirectSshBridgeRuntime { }, currentAuthority: () => null, terminalActions: () => ({}), - prepareAndSync: vi.fn(async () => {}), + prepareHost: vi.fn(async () => {}), isStopped: () => false, addDeadline: vi.fn(), removeDeadline: vi.fn(), diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index c56cc5fac936..a84a35c7c31e 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -20,7 +20,7 @@ export function registerDirectSshStateIpcBridge( reconnectCoordinator, currentAuthority, terminalActions, - prepareAndSync + prepareHost } = runtime const sshStateWatermarkByTargetId = new Map() // Connect replies can update the store before this bridge routes the matching push. @@ -190,7 +190,7 @@ export function registerDirectSshStateIpcBridge( terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? 0, retryTargetPanes: (nextAuthority) => terminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, - prepareAndSync: prepareAndSync, + prepareHost, rememberReconnectAuthority: (nextAuthority) => { if (nextAuthority) { reconnectAuthorityByTarget.set(targetId, nextAuthority) @@ -261,7 +261,7 @@ export function registerDirectSshStateIpcBridge( getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], wakeAuthority: (authority) => { reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareAndSync(authority, 'wake-refresh') + void prepareHost(authority, 'wake-refresh') }, ...(typeof window.api.ui.onSystemResumed === 'function' ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts deleted file mode 100644 index 161d6eaf576a..000000000000 --- a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useAppStore } from '../../store' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' - -export function registerRemoteWorkspaceIpcBridge( - unsubs: (() => void)[], - runtime: DirectSshBridgeRuntime -): void { - let clientId: string | null = null - let clientIdPromise: Promise | null = null - const getClientId = (): Promise => { - const remoteWorkspace = window.api.remoteWorkspace - if (!remoteWorkspace) { - return Promise.resolve(null) - } - if (clientId) { - return Promise.resolve(clientId) - } - clientIdPromise ??= remoteWorkspace - .clientId() - .then((id) => { - clientId = id - return id - }) - .catch(() => null) - return clientIdPromise - } - if (!window.api.remoteWorkspace) { - return - } - void getClientId() - unsubs.push( - window.api.remoteWorkspace.onChanged((event) => { - void (async () => { - const currentClientId = await getClientId() - if (event.sourceClientId && currentClientId && event.sourceClientId === currentClientId) { - return - } - await runtime.remoteWorkspaceTargetSync - ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) - .catch((error) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { - phase: 'error', - revision: event.snapshot.revision, - message: error instanceof Error ? error.message : 'Failed to apply remote workspace' - }) - }) - })() - }) - ) -} diff --git a/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts b/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts deleted file mode 100644 index 5fc1068dea74..000000000000 --- a/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import { createDeferredSnapshotPlacementRetries } from './remote-workspace-deferred-placement-retry' -import type { RemoteWorkspaceSnapshotPlacementStore } from './remote-workspace-snapshot-placement' -import { - appState, - flush, - owner, - snapshot -} from './__tests__/remote-workspace-target-sync-test-harness' - -/** `appState()` carries worktree `repo-a::/remote/work`, so this path is placeable at once. */ -const PLACEABLE_PATH = '/remote/work' - -function placementStore(): RemoteWorkspaceSnapshotPlacementStore { - const state = appState() - return { getState: () => state, subscribe: () => () => {} } -} - -const rejections: unknown[] = [] -const onUnhandled = (reason: unknown): void => { - rejections.push(reason) -} -process.on('unhandledRejection', onUnhandled) -afterEach(() => { - rejections.length = 0 -}) - -describe('createDeferredSnapshotPlacementRetries', () => { - it('swallows a getSnapshot rejection instead of leaving it unhandled', async () => { - const applySnapshot = vi.fn(async () => {}) - const retries = createDeferredSnapshotPlacementRetries({ - store: placementStore(), - getCurrentAuthority: () => owner, - getSnapshot: () => Promise.reject(new Error('relay dropped')), - applySnapshot - }) - - retries.watch(owner, [PLACEABLE_PATH]) - await flush() - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(rejections).toEqual([]) - // A failed pull is unverifiable, so the target is left on `conflict` rather than re-applied. - expect(applySnapshot).not.toHaveBeenCalled() - retries.stop() - }) - - it('swallows an applySnapshot rejection instead of leaving it unhandled', async () => { - const retries = createDeferredSnapshotPlacementRetries({ - store: placementStore(), - getCurrentAuthority: () => owner, - getSnapshot: async () => snapshot(4), - applySnapshot: () => Promise.reject(new Error('relay dropped')) - }) - - retries.watch(owner, [PLACEABLE_PATH]) - await flush() - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(rejections).toEqual([]) - retries.stop() - }) - - it('bounds an apply that keeps re-arming the same unplaced paths', async () => { - // Guards the test itself: without a bound the chain never yields and the run would hang. - const CHAIN_GUARD = 50 - let pulls = 0 - const retries = createDeferredSnapshotPlacementRetries({ - store: placementStore(), - getCurrentAuthority: () => owner, - getSnapshot: async (): Promise => { - pulls += 1 - return snapshot(4 + pulls) - }, - applySnapshot: async () => { - // The apply reports the same still-unplaced path, which arms the next watch. - if (pulls < CHAIN_GUARD) { - retries.watch(owner, [PLACEABLE_PATH]) - } - } - }) - - retries.watch(owner, [PLACEABLE_PATH]) - for (let tick = 0; tick < CHAIN_GUARD * 2; tick += 1) { - await flush() - } - - expect(pulls).toBeLessThan(CHAIN_GUARD) - expect(pulls).toBe(3) - retries.stop() - }) - - it('lets a chain that keeps placing rows run past the stalled bound', async () => { - // Five placeable paths, one fewer unplaced each round: that chain is converging and is already - // bounded by the set emptying, so the stall bound must not cut it off at 3. - const state = appState({ - worktreesByRepo: { - 'repo-a': [1, 2, 3, 4, 5].map((n) => ({ - id: `repo-a::/remote/w${n}`, - repoId: 'repo-a', - hostId: 'ssh:target-a' - })) - } - }) - const allPaths = [1, 2, 3, 4, 5].map((n) => `/remote/w${n}`) - let pulls = 0 - let remaining = allPaths.length - const retries = createDeferredSnapshotPlacementRetries({ - store: { getState: () => state, subscribe: () => () => {} }, - getCurrentAuthority: () => owner, - getSnapshot: async (): Promise => { - pulls += 1 - return snapshot(4 + pulls) - }, - applySnapshot: async () => { - remaining -= 1 - retries.watch(owner, allPaths.slice(0, remaining)) - } - }) - - retries.watch(owner, allPaths) - for (let tick = 0; tick < 40; tick += 1) { - await flush() - } - - // One pull per round until the unplaced set empties -- five, not the stall bound of three. - expect(pulls).toBe(5) - retries.stop() - }) - - it('does not spend the chain budget on watches armed outside a retry', async () => { - let pulls = 0 - const retries = createDeferredSnapshotPlacementRetries({ - store: placementStore(), - getCurrentAuthority: () => owner, - getSnapshot: async (): Promise => { - pulls += 1 - return snapshot(4 + pulls) - }, - applySnapshot: async () => {} - }) - - // Each of these is a fresh host snapshot arrival, not a continued chain. - for (let arrival = 0; arrival < 6; arrival += 1) { - retries.watch(owner, [PLACEABLE_PATH]) - await flush() - await flush() - } - - expect(pulls).toBe(6) - retries.stop() - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts b/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts deleted file mode 100644 index c3139f85f47e..000000000000 --- a/src/renderer/src/hooks/remote-workspace-deferred-placement-retry.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' -import { - waitForSnapshotWorktreePlacement, - type RemoteWorkspaceSnapshotPlacementStore -} from './remote-workspace-snapshot-placement' - -/** How long a conflicted target keeps watching the catalog for the rows it could not place. The - * catalog for a remote host often only fills in when the user opens the worktree, which is minutes - * after connect, and `conflict` has no other exit: it suppresses uploads and holds terminal - * authority for those paths at `unverifiable` until a reconnect or an unsolicited host push - * happens to arrive. */ -const DEFERRED_SNAPSHOT_PLACEMENT_TIMEOUT_MS = 600_000 - -/** A retry's own apply can report paths it still could not place, which arms the next watch. If the - * catalog reports those paths placeable, that watch fires at once and the chain never yields. - * - * Counted only while the unplaced set stops shrinking: a chain that keeps placing rows is - * converging and is already bounded by that set emptying, so cutting it short would strand the - * target on `conflict` for no reason. A chain that re-arms on the same or a larger set is the - * spinning case, and that is what this bounds. */ -const MAX_STALLED_DEFERRED_PLACEMENT_CHAIN = 3 - -export type DeferredSnapshotPlacementRetryDeps = { - store: RemoteWorkspaceSnapshotPlacementStore - getCurrentAuthority: (targetId: string) => DirectSshAuthority | null - getSnapshot: (targetId: string) => Promise - applySnapshot: (targetId: string, snapshot: RemoteWorkspaceObservedSnapshot) => Promise -} - -export type DeferredSnapshotPlacementRetries = { - /** Empty `worktreePaths` retires the target's outstanding watch instead of arming one. */ - watch: (authority: DirectSshAuthority, worktreePaths: readonly string[]) => void - stop: () => void -} - -/** - * Re-pull once the local catalog can place the host paths an apply had to drop. - * - * Why a fresh pull rather than replaying the snapshot already held: that snapshot is only evidence - * of what the host had when it was taken. The host owns execution state, so the answer acted on has - * to be its current one — see docs/reference/ssh-execution-boundary.md. A pull that comes back - * empty or fails is left alone: it is `unverifiable`, and moving the target off `conflict` on it - * would re-authorise uploads and sleeping-agent resume from a picture known to be incomplete. - */ -export function createDeferredSnapshotPlacementRetries( - deps: DeferredSnapshotPlacementRetryDeps -): DeferredSnapshotPlacementRetries { - const watchers = new Map() - /** Consecutive non-shrinking re-arms, and the set size they stalled at. Absent means no chain. */ - const chains = new Map() - const applying = new Set() - let stopped = false - - const watch = (authority: DirectSshAuthority, worktreePaths: readonly string[]): void => { - // Always retire the previous watch: an apply that placed everything passes no paths, and that - // is exactly when the outstanding watch is obsolete. - watchers.get(authority.targetId)?.abort() - watchers.delete(authority.targetId) - // A watch armed from outside a retry is a fresh snapshot arrival, not a continued chain. - const previous = applying.has(authority.targetId) ? chains.get(authority.targetId) : undefined - if (stopped || worktreePaths.length === 0) { - chains.delete(authority.targetId) - return - } - const stalledDepth = - previous && worktreePaths.length >= previous.unplacedCount ? previous.stalledDepth + 1 : 0 - if (stalledDepth >= MAX_STALLED_DEFERRED_PLACEMENT_CHAIN) { - // Leave the target on `conflict`: the paths are not becoming placeable by re-pulling. - chains.delete(authority.targetId) - return - } - chains.set(authority.targetId, { stalledDepth, unplacedCount: worktreePaths.length }) - const controller = new AbortController() - watchers.set(authority.targetId, controller) - const isCurrent = (): boolean => - !stopped && - watchers.get(authority.targetId) === controller && - directSshAuthoritiesEqual(deps.getCurrentAuthority(authority.targetId), authority) - void (async () => { - try { - const placed = await waitForSnapshotWorktreePlacement( - deps.store, - authority, - worktreePaths, - isCurrent, - controller.signal, - DEFERRED_SNAPSHOT_PLACEMENT_TIMEOUT_MS - ) - if (!placed || !isCurrent()) { - return - } - const snapshot = await deps.getSnapshot(authority.targetId) - if (!snapshot || snapshot.revision <= 0 || !isCurrent()) { - return - } - applying.add(authority.targetId) - try { - await deps.applySnapshot(authority.targetId, snapshot) - } finally { - applying.delete(authority.targetId) - } - } catch { - // `getSnapshot` is an IPC call that rejects when the relay drops, and the apply can reject - // with it. A failed pull is `unverifiable`, and the documented behaviour is to leave the - // target on `conflict` rather than act on a picture known to be incomplete -- so swallow it - // here instead of surfacing an unhandled rejection. - } finally { - // A still-current controller means the apply did not re-arm, so the chain ends here. - if (watchers.get(authority.targetId) === controller) { - watchers.delete(authority.targetId) - chains.delete(authority.targetId) - } - } - })() - } - - return { - watch, - stop: () => { - stopped = true - for (const controller of watchers.values()) { - controller.abort() - } - watchers.clear() - chains.clear() - } - } -} diff --git a/src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts b/src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts deleted file mode 100644 index 716c9ab1b2fb..000000000000 --- a/src/renderer/src/hooks/remote-workspace-push-status-snapshot-lifetime.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' -import { useAppStore } from '@/store' -import { snapshot } from './__tests__/remote-workspace-target-sync-test-harness' -import { - applyRemoteWorkspacePushStatus, - type RemoteWorkspacePushAuthority -} from './remote-workspace-push-status' - -const TARGET_ID = 'target-a' -const NOW = 100 -const initialAppStoreState = useAppStore.getState() -const unavailableMessage = 'Remote workspace sync unavailable' -const conflictMessage = 'Workspace changed on another device' -const fallbackResponses = [ - { name: 'missing reply', result: undefined, phase: 'offline', message: unavailableMessage }, - { - name: 'snapshot-free conflict', - result: { ok: false, reason: 'stale-revision' }, - phase: 'conflict', - message: conflictMessage - }, - { - name: 'snapshot-free unavailable', - result: { ok: false, reason: 'unavailable' }, - phase: 'offline', - message: unavailableMessage - } -] as const - -async function collectRetiredSnapshots(): Promise { - if (typeof globalThis.gc !== 'function') { - throw new Error('Run with the repository Vitest --expose-gc config') - } - for (let round = 0; round < 3; round += 1) { - await new Promise((resolve) => setImmediate(resolve)) - globalThis.gc() - } -} - -function publishFailedSnapshot(reason: 'stale-revision' | 'unavailable') { - const observed = snapshot(8) - const leafId = '11111111-1111-4111-8111-111111111111' - observed.session.activeWorktreePath = '/remote/work' - observed.session.activeTabId = 'host-tab' - observed.session.tabsByWorktreePath = { - '/remote/work': [ - { - id: 'host-tab', - worktreePath: '/remote/work', - ptyId: null, - title: 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - observed.session.terminalLayoutsByTabId = { - 'host-tab': { - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - expandedLeafId: null, - buffersByLeafId: { [leafId]: 'Agent progress\n'.repeat(4_096) } - } - } - const retiredSession = new WeakRef(observed.session) - applyRemoteWorkspacePushStatus( - useAppStore.getState(), - TARGET_ID, - { ok: false, reason, snapshot: observed, message: 'Retry upload' }, - { revision: 0, hostObservationToken: 'older-observation' } - ) - useAppStore.getState().setSshConnectionState(TARGET_ID, { - targetId: TARGET_ID, - status: 'disconnected', - error: null, - reconnectAttempt: 0 - }) - return retiredSession -} - -beforeEach(() => { - useAppStore.setState(initialAppStoreState, true) - vi.spyOn(Date, 'now').mockReturnValue(NOW) -}) - -afterEach(() => { - useAppStore.setState(initialAppStoreState, true) - vi.restoreAllMocks() -}) - -describe('remote workspace push status snapshot lifetime', () => { - it.each(['stale-revision', 'unavailable'] as const)( - 'releases the %s session while its status survives disconnect', - async (reason) => { - const retiredSession = publishFailedSnapshot(reason) - const status = useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID] - const expected = { - phase: reason === 'stale-revision' ? 'conflict' : 'offline', - direction: 'push', - revision: 8, - updatedAt: 8, - hostObservationToken: 'observation-8', - lastSyncedAt: NOW, - message: 'Retry upload' - } - expect(status).toMatchObject(expected) - expect(useAppStore.getState().sshConnectionStates.get(TARGET_ID)?.status).toBe('disconnected') - - await collectRetiredSnapshots() - expect(retiredSession.deref()).toBeUndefined() - expect(status).toEqual(expected) - expect(Object.keys(status)).toEqual(Object.keys(expected)) - } - ) - - it('preserves a successful upload status', () => { - applyRemoteWorkspacePushStatus( - useAppStore.getState(), - TARGET_ID, - { ok: true, snapshot: snapshot(9) }, - { revision: 0, hostObservationToken: 'older-observation' } - ) - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toEqual({ - phase: 'synced', - direction: 'push', - revision: 9, - updatedAt: 9, - hostObservationToken: 'observation-9', - lastSyncedAt: NOW, - message: 'Workspace uploaded' - }) - }) - - it.each(fallbackResponses)('preserves full observed fallback keys for $name', (response) => { - const fallback = snapshot(0) - applyRemoteWorkspacePushStatus(useAppStore.getState(), TARGET_ID, response.result, fallback) - const status = useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID] - const expected = { - phase: response.phase, - direction: 'push', - ...fallback, - lastSyncedAt: NOW, - message: response.message - } - expect(status).toEqual(expected) - expect(Object.keys(status)).toEqual(Object.keys(expected)) - const retainedStatus: unknown = status - if ( - typeof retainedStatus !== 'object' || - retainedStatus === null || - !('session' in retainedStatus) - ) { - throw new Error('The full observed fallback keeps its existing session property') - } - expect(retainedStatus.session).toBe(fallback.session) - }) - - it.each([{}, { updatedAt: undefined }, { updatedAt: 13 }] as const)( - 'preserves fallback updatedAt own-key presence: %j', - (extra) => { - const fallback: RemoteWorkspacePushAuthority = { - revision: 7, - hostObservationToken: 'fallback-observation', - ...extra - } - applyRemoteWorkspacePushStatus(useAppStore.getState(), TARGET_ID, undefined, fallback) - const status = useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID] - expect(status).toEqual({ - phase: 'offline', - direction: 'push', - ...fallback, - lastSyncedAt: NOW, - message: unavailableMessage - }) - expect(Object.hasOwn(status, 'updatedAt')).toBe(Object.hasOwn(fallback, 'updatedAt')) - } - ) - - it.each(fallbackResponses)('preserves matching transient authority for $name', (response) => { - const fallback = snapshot(0) - useAppStore.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { - phase: 'pulling', - revision: -1, - updatedAt: undefined, - hostObservationToken: fallback.hostObservationToken - }) - applyRemoteWorkspacePushStatus(useAppStore.getState(), TARGET_ID, response.result, fallback) - const status = useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID] - expect(status).toEqual({ - phase: response.phase, - direction: 'push', - revision: -1, - updatedAt: undefined, - hostObservationToken: fallback.hostObservationToken, - lastSyncedAt: NOW, - message: response.message - }) - expect(Object.hasOwn(status, 'updatedAt')).toBe(true) - expect(Object.hasOwn(status, 'session')).toBe(false) - }) - - it('preserves an empty custom failure message', () => { - const result: RemoteWorkspaceObservedPatchResult = { - ok: false, - reason: 'stale-revision', - snapshot: snapshot(10), - message: '' - } - applyRemoteWorkspacePushStatus(useAppStore.getState(), TARGET_ID, result, snapshot(0)) - expect(useAppStore.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]).toMatchObject({ - phase: 'conflict', - direction: 'push', - revision: 10, - updatedAt: 10, - hostObservationToken: 'observation-10', - lastSyncedAt: NOW, - message: '' - }) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-push-status.ts b/src/renderer/src/hooks/remote-workspace-push-status.ts deleted file mode 100644 index 4a3792114817..000000000000 --- a/src/renderer/src/hooks/remote-workspace-push-status.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' -import { translate } from '@/i18n/i18n' -import type { AppState } from '../store/types' - -export type RemoteWorkspacePushAuthority = { - revision: number - updatedAt?: number - hostObservationToken: string -} - -function currentTransientAuthority( - store: AppState, - targetId: string, - fallback: RemoteWorkspacePushAuthority -): RemoteWorkspacePushAuthority { - const current = store.remoteWorkspaceSyncStatusByTargetId[targetId] - return current?.hostObservationToken === fallback.hostObservationToken && - typeof current.revision === 'number' - ? { - revision: current.revision, - updatedAt: current.updatedAt, - hostObservationToken: current.hostObservationToken - } - : fallback -} - -export function applyRemoteWorkspacePushStatus( - store: AppState, - targetId: string, - result: RemoteWorkspaceObservedPatchResult | undefined, - fallbackAuthority: RemoteWorkspacePushAuthority -): void { - if (!result) { - const authority = currentTransientAuthority(store, targetId, fallbackAuthority) - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: 'offline', - direction: 'push', - ...authority, - 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, - hostObservationToken: result.snapshot.hostObservationToken, - lastSyncedAt: Date.now(), - message: translate('auto.hooks.useIpcEvents.f8aaf2bde3', 'Workspace uploaded') - }) - } else { - const authority = result.snapshot - ? { - revision: result.snapshot.revision, - updatedAt: result.snapshot.updatedAt, - hostObservationToken: result.snapshot.hostObservationToken - } - : currentTransientAuthority(store, targetId, fallbackAuthority) - store.setRemoteWorkspaceSyncStatus(targetId, { - phase: result.reason === 'stale-revision' ? 'conflict' : 'offline', - direction: 'push', - ...authority, - 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')) - }) - } -} diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts deleted file mode 100644 index 5fbdd00ffed5..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { mergeDirectSshRemoteWorkspaceSession } from './remote-workspace-session-merge' -import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { AppState } from '../store/types' - -/** - * The one thing a close tombstone is allowed to do, and everything it is not. - * - * The bug it exists for: the `pty.kill` for a closed SSH tab rejects with a transport error, so the - * close never reaches the host, the host keeps listing the tab, and every reconnect re-inserts it. - * The bug it must never cause is the opposite one — PR #14361 was reverted for deleting live tabs — - * so every case below that is not "an id this client watched the user close" keeps the tab. - */ -const WORKTREE = 'repo-1::/home/user/bug-cats' - -function terminalTab(id: string, overrides: Partial = {}): TerminalTab { - return { id, title: id, type: 'terminal', worktreeId: WORKTREE, ...overrides } as TerminalTab -} - -function sessionState(overrides: Partial = {}): WorkspaceSessionState { - return { - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE, - activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE), - activeTabId: 'agent', - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - activeTabIdByWorktree: {}, - ...overrides - } as WorkspaceSessionState -} - -// Records ride on `current` here for brevity; the caller passes the renderer's read-only mirror. -function merge( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - liveTabs: AppState['tabsByWorktree'] = {} -): WorkspaceSessionState { - return mergeDirectSshRemoteWorkspaceSession( - { ...current, closedTerminalTabTombstonesByTabId: undefined }, - remote, - new Set([WORKTREE]), - liveTabs, - new Set(), - undefined, - current.closedTerminalTabTombstonesByTabId - ) -} - -function tombstone(tabId: string): WorkspaceSessionState { - return sessionState({ - tabsByWorktree: { [WORKTREE]: [] }, - closedTerminalTabTombstonesByTabId: { - [tabId]: { closedAt: Date.now(), worktreeId: WORKTREE } - } - }) -} - -describe('direct-SSH pull merge: closed-tab tombstones', () => { - it('does not suppress a host tab whose id collides with an Object.prototype key', () => { - // `tabId in map` answers true for every prototype key even on an EMPTY map, so a host tab named - // `toString` was filtered out, blocked from the host-unknown branch, and stripped of its layout - // and session id — the one path in this function that could delete a tab the user never closed. - // Tab ids are validated only as non-empty and colon-free, and createTab honours caller-supplied - // id hints, so the id is reachable rather than theoretical. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [terminalTab('toString')] }, - terminalLayoutsByTabId: { toString: { root: { type: 'leaf', paneId: 'p' } } as never }, - remoteSessionIdsByTabId: { toString: 'session-1' } - }) - - const merged = merge(current, remote, {}) - - expect(merged.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['toString']) - expect(Object.hasOwn(merged.terminalLayoutsByTabId, 'toString')).toBe(true) - expect(merged.remoteSessionIdsByTabId?.toString).toBe('session-1') - }) - - it('does not suppress a tombstoned id under a DIFFERENT worktree', () => { - // The tombstone carries the worktree it was closed in, so suppression cannot reach another - // workspace's tab. Structural, rather than a property of where the call sites happen to sit. - const other = 'repo-1::/home/user/other' - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [] }, - closedTerminalTabTombstonesByTabId: { - 'tab-x': { closedAt: Date.now(), worktreeId: other } - } - }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('tab-x')] } }) - - const merged = merge(current, remote, {}) - - expect(merged.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab-x']) - }) - - it('does not re-insert a tombstoned tab the host still lists', () => { - const ghost = terminalTab('ghost') - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [ghost] }, - terminalLayoutsByTabId: { ghost: { type: 'single', tabId: 'ghost' } as never }, - remoteSessionIdsByTabId: { ghost: 'pty-1' } - }) - - const merged = merge(tombstone('ghost'), remote, { [WORKTREE]: [] }) - - expect(merged.tabsByWorktree[WORKTREE]).toEqual([]) - expect(merged.terminalLayoutsByTabId.ghost).toBeUndefined() - expect(merged.remoteSessionIdsByTabId?.ghost).toBeUndefined() - }) - - it('does not re-add a tombstoned tab through the host-unknown branch', () => { - // The stale persisted payload still lists it, and the host has never heard of it. Without the - // tombstone that combination is exactly what the host-unknown branch exists to preserve. - const ghost = terminalTab('ghost') - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [ghost] }, - closedTerminalTabTombstonesByTabId: { ghost: { closedAt: Date.now(), worktreeId: WORKTREE } } - }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - - const merged = merge(current, remote, {}) - - expect(merged.tabsByWorktree[WORKTREE]).toEqual([]) - }) - - it('suppresses only the tombstoned tab, leaving its siblings and the pointers alone', () => { - // The active-tab pointers deliberately still name the ghost: hydration validates both of them - // against the rows below and nulls what they no longer name, so the merge leaves them be. - const ghost = terminalTab('ghost') - const survivor = terminalTab('survivor') - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [ghost, survivor] }, - activeTabId: 'ghost', - activeTabIdByWorktree: { [WORKTREE]: 'ghost' } - }) - - const merged = merge(tombstone('ghost'), remote, { [WORKTREE]: [] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['survivor']) - }) - - it('a live local tab beats a stale tombstone for its id', () => { - const live = terminalTab('live') - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [live] }, - closedTerminalTabTombstonesByTabId: { live: { closedAt: Date.now(), worktreeId: WORKTREE } } - }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [live] } }) - - const merged = merge(current, remote, { [WORKTREE]: [live] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['live']) - }) - - it('neither retires nor writes back a record once the host stops listing the tab', () => { - // Main alone writes records; an acknowledgement used to delete them here, which made a workspace - // the user emptied read as never initialized as soon as the host caught up. - const current = tombstone('ghost') - const merged = merge(current, sessionState({ tabsByWorktree: { [WORKTREE]: [] } }), { - [WORKTREE]: [] - }) - - expect(merged.closedTerminalTabTombstonesByTabId).toBeUndefined() - expect(current.closedTerminalTabTombstonesByTabId?.ghost).toBeDefined() - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts deleted file mode 100644 index ff817913da43..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { mergeDirectSshRemoteWorkspaceSession } from './remote-workspace-session-merge' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' -import { - makeCreatedAgentWorktree as makeWorktree, - seedEmptyActivatableWorktree -} from '@/lib/worktree-activation-created-agent-test-state' -import { useAppStore } from '@/store' - -/** - * The closed-last-terminal tombstone across a direct-SSH reconnect, end to end. - * - * The unit-level rule lives in remote-workspace-session-merge-local-survival.test.ts; this asserts - * what that rule is FOR. An empty row plus a close record has to keep meaning one thing — the - * merge dropping the key, or the re-hydration dropping the record, turns "the user closed the last - * terminal" into "never initialized", and the seeding pass then hands the terminal back on every - * reconnect. - */ -const initialAppStoreState = useAppStore.getState() - -afterEach(() => { - vi.unstubAllGlobals() - useAppStore.setState(initialAppStoreState, true) -}) - -/** The state a workspace lands in once its last terminal is closed: an explicit empty row, and - * the close record the renderer mirrors from main. */ -function seedClosedLastTerminal(worktreeId: string): void { - useAppStore.setState({ - tabsByWorktree: { [worktreeId]: [] }, - closedTerminalTabTombstonesByTabId: { closed: { closedAt: Date.now(), worktreeId } } - }) - expect(useAppStore.getState().reconcileWorktreeTabModel(worktreeId).renderableTabCount).toBe(0) -} - -/** The real merge against a host that has no row for this worktree, hydrated the way - * remote-workspace-snapshot-apply does it. */ -function applyHostSnapshotOmitting(worktreeId: string): void { - const state = useAppStore.getState() - const merged = mergeDirectSshRemoteWorkspaceSession( - buildWorkspaceSessionPayload(state), - getDefaultWorkspaceSession(), - new Set([worktreeId]), - state.tabsByWorktree, - new Set() - ) - const replaceWorkspaceKeys = [worktreeId] - useAppStore.getState().hydrateWorkspaceSession(merged, { replaceWorkspaceKeys }) - useAppStore.getState().hydrateTabsSession(merged, { replaceWorkspaceKeys }) -} - -describe('a closed-last-terminal row that a direct-SSH snapshot says nothing about', () => { - it('survives the reconnect, so startup hydration adds no terminal', () => { - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - - applyHostSnapshotOmitting(worktree.id) - - expect(Object.hasOwn(useAppStore.getState().tabsByWorktree, worktree.id)).toBe(true) - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) - expect(ensureWorktreeHasInitialTerminal(useAppStore.getState(), worktree.id)).toBeNull() - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([]) - }) - - it('still re-seeds when the user explicitly opens the workspace', () => { - // The mirror risk of preserving the row rather than dropping it: the tombstone must suppress - // seeding on startup hydration ONLY. An explicit activation opts into reseedEmptiedWorkspace, - // and a preserved row that swallowed it would trade a duplicate terminal for a dead workspace. - // Passes before the merge fix too — it is the guard on it, not evidence of it. - const worktree = makeWorktree() - seedEmptyActivatableWorktree(worktree) - seedClosedLastTerminal(worktree.id) - - applyHostSnapshotOmitting(worktree.id) - const result = activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) - - expect(result).not.toBe(false) - expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts deleted file mode 100644 index 8889d322bb38..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-merge-local-selection.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mergeDirectSshRemoteWorkspaceSession } from './remote-workspace-session-merge' -import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { TerminalTab } from '../../../shared/terminal-tab-types' - -/** - * #19697 / #20938: every direct-SSH host republish (a title change while an agent works) carries - * the last uploaded selection. Selection is per view, so after the first hydrate it must not move. - */ -const WORKTREE = 'repo-1::/home/user/app' -const OTHER = 'repo-1::/home/user/other' - -function tab(id: string, title = id, worktreeId = WORKTREE): TerminalTab { - return { - id, - ptyId: null, - worktreeId, - title, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -function session( - activeTabId: string | null, - tabs: TerminalTab[], - overrides: Partial = {} -): WorkspaceSessionState { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields below are every one the merge reads for selection. - return { - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE, - activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE), - activeTabId, - tabsByWorktree: { [WORKTREE]: tabs }, - terminalLayoutsByTabId: {}, - activeTabIdByWorktree: activeTabId ? { [WORKTREE]: activeTabId } : {}, - ...overrides - } as WorkspaceSessionState -} - -function merge( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - hydrated: boolean -): WorkspaceSessionState { - return mergeDirectSshRemoteWorkspaceSession( - current, - remote, - new Set([WORKTREE, OTHER]), - current.tabsByWorktree, - new Set(), - 'ssh:target-1', - undefined, - new Set(), - hydrated - ) -} - -const local = [tab('tab-1'), tab('tab-2'), tab('tab-3')] -const republished = [tab('tab-1', 'claude: working'), tab('tab-2'), tab('tab-3')] - -describe('direct-SSH merge keeps this view’s selection after the first hydrate', () => { - it('keeps the user’s tab when a host republish names another one', () => { - const merged = merge(session('tab-3', local), session('tab-1', republished), true) - - expect(merged.activeTabId).toBe('tab-3') - expect(merged.activeTabIdByWorktree?.[WORKTREE]).toBe('tab-3') - expect(merged.tabsByWorktree[WORKTREE]?.[0]?.title).toBe('claude: working') - }) - - it('keeps the user’s workspace when the host names a different one', () => { - const remote = session('other-1', republished, { - activeWorktreeId: OTHER, - activeWorkspaceKey: worktreeWorkspaceKey(OTHER), - tabsByWorktree: { [WORKTREE]: republished, [OTHER]: [tab('other-1', 'x', OTHER)] } - }) - - const merged = merge(session('tab-3', local), remote, true) - - expect(merged.activeWorktreeId).toBe(WORKTREE) - expect(merged.activeWorkspaceKey).toBe(worktreeWorkspaceKey(WORKTREE)) - expect(merged.activeTabId).toBe('tab-3') - }) - - it('falls back to the host pointer once the local tab is gone', () => { - const hostTabs = [tab('tab-1'), tab('tab-2')] - const current = session('tab-3', hostTabs) - - const merged = merge(current, session('tab-1', hostTabs), true) - - expect(merged.activeTabIdByWorktree?.[WORKTREE]).toBe('tab-1') - expect(merged.activeTabId).toBe('tab-1') - }) - - it('still adopts the host selection on the first hydrate', () => { - const merged = merge(session('tab-3', local), session('tab-1', republished), false) - - expect(merged.activeTabId).toBe('tab-1') - expect(merged.activeTabIdByWorktree?.[WORKTREE]).toBe('tab-1') - }) - - it('leaves a user on the home screen there when the host names no workspace', () => { - const current = session(null, local, { - activeWorktreeId: null, - activeWorkspaceKey: null, - activeTabIdByWorktree: { [WORKTREE]: 'tab-3' } - }) - const remote = session('tab-1', republished, { - activeRepoId: null, - activeWorktreeId: null, - activeWorkspaceKey: null - }) - - const merged = merge(current, remote, true) - - expect(merged.activeWorktreeId).toBeNull() - expect(merged.activeRepoId).toBeNull() - expect(merged.activeTabIdByWorktree?.[WORKTREE]).toBe('tab-3') - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts deleted file mode 100644 index 7bf2f2e2d5f8..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { mergeDirectSshRemoteWorkspaceSession } from './remote-workspace-session-merge' -import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { AppState } from '../store/types' - -/** - * What a reconnect is allowed to do to local state. - * - * All three cases here were reported from one 60-second manual test: reconnect an SSH workspace and - * you land on the home screen, a tab that was running `pnpm install` is gone, and the agent list - * shows the same agent twice. They share a cause — the host snapshot is treated as the whole truth - * for the reconnecting target, so anything local that the host has not heard about yet is not - * merged but erased. - * - * The host is authoritative for what it knows. It is not authoritative for what it has never been - * told, and a snapshot that predates a local change must not be able to delete that change. - */ -const WORKTREE = 'repo-1::/home/user/bug-cats' -const OTHER_WORKTREE = 'repo-1::/home/user/other' - -function terminalTab(id: string, overrides: Partial = {}): TerminalTab { - return { - id, - title: id, - type: 'terminal', - worktreeId: WORKTREE, - ...overrides - } as TerminalTab -} - -function sessionState(overrides: Partial = {}): WorkspaceSessionState { - return { - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE, - activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE), - activeTabId: 'agent', - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - activeTabIdByWorktree: {}, - ...overrides - } as WorkspaceSessionState -} - -function merge( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - liveTabs: AppState['tabsByWorktree'] = {} -): WorkspaceSessionState { - return mergeDirectSshRemoteWorkspaceSession( - current, - remote, - new Set([WORKTREE]), - liveTabs, - new Set() - ) -} - -describe('direct-SSH reconnect merge: local state the host has not seen', () => { - it('keeps a local tab the host snapshot does not contain', () => { - // The reported "setup" tab: created locally, running pnpm install, never uploaded because the - // write was suppressed. The host lists only the agent tab. - const setup = terminalTab('setup') - const agent = terminalTab('agent') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, setup] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - - const merged = merge(current, remote, { [WORKTREE]: [agent, setup] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toContain('setup') - }) - - it('keeps a tab another client closed, which is the accepted cost of the rule', () => { - // Pinned because it is a deliberate trade, not an oversight. Absence in the snapshot cannot - // distinguish "never uploaded" from "closed on another client sharing this host", and the two - // outcomes are not symmetric: keeping a tab a moment too long is recoverable, deleting a live - // one is not. Closing on THIS client removes it from local state, so the common case never - // reaches here. - const agent = terminalTab('agent') - const closedElsewhere = terminalTab('closed-elsewhere') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, closedElsewhere] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - - const merged = merge(current, remote, { [WORKTREE]: [agent, closedElsewhere] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toContain('closed-elsewhere') - }) - - it('invents no tab that is in neither the host snapshot nor local state', () => { - const agent = terminalTab('agent') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - - const merged = merge(current, remote, { [WORKTREE]: [agent] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['agent']) - }) - - it('keeps local tabs when the host snapshot has no entry for the worktree at all', () => { - // A first-revision snapshot can omit the worktree entirely. Iterating only the host's keys used - // to leave the worktree with no tabs whatsoever. - const agent = terminalTab('agent') - const setup = terminalTab('setup') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, setup] } }) - const remote = sessionState({ tabsByWorktree: {} }) - - const merged = merge(current, remote, { [WORKTREE]: [agent, setup] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['agent', 'setup']) - }) - - it('never duplicates a tab that exists on both sides', () => { - // The reported double agent entry: one launch must never merge into two rows. - const agent = terminalTab('agent') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - - const merged = merge(current, remote, { [WORKTREE]: [agent] }) - - const ids = merged.tabsByWorktree[WORKTREE].map((tab) => tab.id) - expect(ids).toEqual([...new Set(ids)]) - expect(ids.filter((id) => id === 'agent')).toHaveLength(1) - }) - - it('does not show one agent twice when the host re-lists its session under a new tab id', () => { - // The reported duplicate grok. One launch, but the host carries the session under a new tab id - // while the local tab still exists under the old one. Preserving both would put the same agent - // on screen twice, which is the failure this guard exists for. - const localGrok = terminalTab('grok-old') - const hostGrok = terminalTab('grok-new') - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [localGrok] }, - remoteSessionIdsByTabId: { 'grok-old': 'session-grok' } - }) - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [hostGrok] }, - remoteSessionIdsByTabId: { 'grok-new': 'session-grok' } - }) - - const merged = merge(current, remote, { [WORKTREE]: [localGrok] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['grok-new']) - }) - - it('still keeps a host-unknown tab that carries no session id', () => { - // The session guard must not swallow the setup tab, which has no agent session at all. - const setup = terminalTab('setup') - const agent = terminalTab('agent') - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [agent, setup] }, - remoteSessionIdsByTabId: { agent: 'session-grok' } - }) - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [agent] }, - remoteSessionIdsByTabId: { agent: 'session-grok' } - }) - - const merged = merge(current, remote, { [WORKTREE]: [agent, setup] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['agent', 'setup']) - }) - - it('keeps the user on their workspace when the host snapshot has no active worktree', () => { - // The reported home screen. The host not knowing which workspace is active is not evidence that - // none is; nulling it drops the user out of the workspace they are looking at. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) - const remote = sessionState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - activeRepoId: null, - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] }) - - expect(merged.activeWorktreeId).toBe(WORKTREE) - expect(merged.activeWorkspaceKey).toBe(worktreeWorkspaceKey(WORKTREE)) - }) - - it('keeps repo and worktree describing the same workspace when the host names only a repo', () => { - // The pair used to split exactly here: the host naming no worktree is precisely when it can - // still name a repo, so activeRepoId followed the host while activeWorktreeId stayed local. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) - const remote = sessionState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - activeRepoId: 'some-other-repo', - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] }) - - expect(merged.activeWorktreeId).toBe(WORKTREE) - expect(merged.activeRepoId, 'repo and worktree describe different workspaces').toBe('repo-1') - expect(merged.activeWorkspaceKey).toBe(worktreeWorkspaceKey(WORKTREE)) - }) - - it('takes the host repo when it does name a worktree', () => { - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) - const remote = sessionState({ - activeWorktreeId: OTHER_WORKTREE, - activeRepoId: 'remote-repo', - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] }) - - expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE) - expect(merged.activeRepoId).toBe('remote-repo') - }) - - it('still follows the host when it does name an active worktree', () => { - // The preserve above must not freeze the pointer: a host that knows is still authoritative. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) - const remote = sessionState({ - activeWorktreeId: OTHER_WORKTREE, - activeWorkspaceKey: worktreeWorkspaceKey(OTHER_WORKTREE), - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] }) - - expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE) - }) - - it('leaves a workspace outside the reconnecting target untouched', () => { - // Scope guard: reconnecting one host must not move the user out of an unrelated workspace. - const current = sessionState({ - activeWorktreeId: OTHER_WORKTREE, - activeWorkspaceKey: worktreeWorkspaceKey(OTHER_WORKTREE), - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } - }) - const remote = sessionState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] }) - - expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE) - }) - - it('keeps the local active tab for the target when the host names none', () => { - // Same failure one level down: losing the per-worktree active tab reopens the workspace on - // whatever happens to be first. - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [terminalTab('agent'), terminalTab('setup')] }, - activeTabIdByWorktree: { [WORKTREE]: 'setup' } - }) - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }, - activeTabIdByWorktree: {} - }) - - const merged = merge(current, remote, { - [WORKTREE]: [terminalTab('agent'), terminalTab('setup')] - }) - - expect(merged.activeTabIdByWorktree?.[WORKTREE]).toBe('setup') - }) -}) - -describe('a tab id local state already holds under two worktrees', () => { - it('is emitted once, not preserved into both', () => { - // Local-local duplication, which the host-unknown branch did not exclude: it filtered against - // ids the HOST knows, never against ids this same merge had already emitted. Two panes then - // share one entry in terminalLayoutsByTabId and one in remoteSessionIdsByTabId — so one remote - // PTY — and activeTabId can never converge, which strands the active-terminal repair in the - // self-retriggering loop active-tab-owner-worktree.ts exists to mitigate (React #185). - // - // The merge does not create this state. It used to DESTROY it, by deleting every local tab under - // a replaced worktree; keeping live panes cost that accidental cure, so it is made explicit. - const current = sessionState({ - tabsByWorktree: { - [WORKTREE]: [terminalTab('setup')], - [OTHER_WORKTREE]: [terminalTab('setup', { worktreeId: OTHER_WORKTREE })] - } - }) - const remote = sessionState({ activeWorktreeId: null, tabsByWorktree: {} }) - - const merged = mergeDirectSshRemoteWorkspaceSession( - current, - remote, - new Set([WORKTREE, OTHER_WORKTREE]), - current.tabsByWorktree, - new Set() - ) - - const owners = Object.entries(merged.tabsByWorktree) - .filter(([, tabs]) => tabs.some((tab) => tab.id === 'setup')) - .map(([worktreeId]) => worktreeId) - expect(owners, 'one tab id survived under two worktrees').toHaveLength(1) - // The survivor is the worktree the user is standing in, which is the owner - // resolveActiveTabOwnerWorktreeId prefers — so the merge and the repair agree. - expect(owners[0]).toBe(WORKTREE) - }) - - it('still keeps a host-unknown tab in a worktree that shares no ids', () => { - // Guards the obvious over-correction: deduping by id must not swallow distinct tabs. - const current = sessionState({ - tabsByWorktree: { - [WORKTREE]: [terminalTab('setup')], - [OTHER_WORKTREE]: [terminalTab('build', { worktreeId: OTHER_WORKTREE })] - } - }) - const remote = sessionState({ activeWorktreeId: null, tabsByWorktree: {} }) - - const merged = mergeDirectSshRemoteWorkspaceSession( - current, - remote, - new Set([WORKTREE, OTHER_WORKTREE]), - current.tabsByWorktree, - new Set() - ) - - expect(merged.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['setup']) - expect(merged.tabsByWorktree[OTHER_WORKTREE]?.map((tab) => tab.id)).toEqual(['build']) - }) -}) - -describe('local rows the snapshot carries no answer for', () => { - it('keeps the explicit empty row that records a closed last terminal', () => { - // initial-terminal.ts: a missing row means never initialized, an explicit empty row means the - // user closed the last terminal. Dropping the key downgrades the second into the first, so the - // seeding pass re-creates the terminal the user just closed on every reconnect. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - const remote = sessionState({ activeWorktreeId: null, tabsByWorktree: {} }) - - const merged = merge(current, remote, { [WORKTREE]: [] }) - - expect(Object.hasOwn(merged.tabsByWorktree, WORKTREE), 'tombstone erased').toBe(true) - expect(merged.tabsByWorktree[WORKTREE]).toEqual([]) - }) - - it('keeps the user standing in the workspace they emptied', () => { - // Same row, one level up. `localActiveWorkspaceSurvives` counted tabs, so the workspace the - // user had just closed the last terminal in read as "did not survive the merge" and the host's - // null active worktree was taken literally — the home screen, for closing a tab. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - const remote = sessionState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - activeRepoId: null, - tabsByWorktree: { [WORKTREE]: [] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [] }) - - expect(merged.activeWorktreeId).toBe(WORKTREE) - expect(merged.activeWorkspaceKey).toBe(worktreeWorkspaceKey(WORKTREE)) - expect(merged.activeRepoId).toBe('repo-1') - }) - - it('still lets the host move the user off a workspace it does name one for', () => { - // The counterweight: presence must not turn into "never follow the host". A host that names an - // active worktree still wins over the emptied local one. - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - const remote = sessionState({ - activeWorktreeId: OTHER_WORKTREE, - tabsByWorktree: { [WORKTREE]: [] } - }) - - const merged = merge(current, remote, { [WORKTREE]: [] }) - - expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE) - }) - - it('does not stand the user in a workspace that survived in neither side', () => { - // The boundary the presence rule actually draws, and the only case that separates it from - // "always preserve": an emptied row is evidence the workspace exists, but NO row on either - // side is not. Preserving here would leave the user pointed at a workspace the merge has no - // record of, which is the home screen's job to catch. - const current = sessionState() - const remote = sessionState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - activeRepoId: null - }) - - const merged = merge(current, remote) - - expect(merged.activeWorktreeId).toBeNull() - expect(merged.activeWorkspaceKey).toBeNull() - }) - - it('invents no row for a worktree neither side has one for', () => { - // The counterweight: presence has to come from a real local row, not from membership in the - // replace set, or a never-initialized workspace gets a tombstone it never earned. - const current = sessionState({ tabsByWorktree: {} }) - const remote = sessionState({ activeWorktreeId: null, tabsByWorktree: {} }) - - const merged = merge(current, remote, {}) - - expect(Object.hasOwn(merged.tabsByWorktree, WORKTREE)).toBe(false) - }) - - it('keeps the default-terminal-tabs marker the snapshot does not carry', () => { - // The marker is write-once and is the only guard on applyDefaultTerminalTabs. A snapshot that - // omits it is a host that was never told, not a host reporting the tabs were never applied — - // and taking it literally re-applies the whole template on top of the user's tabs. - const agent = terminalTab('agent') - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [agent] }, - defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE]: true } - }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - - const merged = merge(current, remote, { [WORKTREE]: [agent] }) - - expect(merged.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(true) - }) - - // The recovery ledger is client-local: the host has never heard of it and its - // snapshot never carries one. Letting a reconnect erase it hands the tab a - // fresh remount allowance on every republication — which is the remount storm - // (b5cfc6ca) the ledger exists to end, restored on a timer. - describe('client-local recovery ledger', () => { - const ledger = { - attemptedAt: [1000], - generation: 1, - outcome: 'failed' as const, - startedAt: 1000, - reason: 'reattach-unverifiable' as const, - tabGeneration: 1 - } - - it('survives a reconnect the host snapshot knows nothing about', () => { - const local = terminalTab('agent', { generation: 1, recovery: ledger }) - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [local] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) - - const merged = merge(current, remote, { [WORKTREE]: [local] }) - - expect(merged.tabsByWorktree[WORKTREE][0].recovery).toEqual(ledger) - }) - - it('leaves a tab that never recovered without one', () => { - const local = terminalTab('agent') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [local] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) - - const merged = merge(current, remote, { [WORKTREE]: [local] }) - - expect(merged.tabsByWorktree[WORKTREE][0].recovery).toBeUndefined() - }) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts deleted file mode 100644 index c1dd4f7579da..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-merge-parked-scrollback.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { mergeDirectSshRemoteWorkspaceSession } from './remote-workspace-session-merge' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { TerminalTab, TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' -import type { AppState } from '../store/types' - -/** - * A reconnect must not delete the only copy of a parked remote pane's scrollback. - * - * A remote-runtime pty's bytes never transit main, so the park capture in - * `terminalLayoutsByTabId[tabId].buffersByLeafId` is the client's only copy. The reconnect replaces - * a replaced tab's layout wholesale, and a park capture does not bump `tab.generation` — so the tab - * is not in `locallyPreservedTabIds` and, before this was fixed, its scrollback went with the - * layout. The host never mints scrollback of its own; its copy is only ever an earlier upload. - */ -const WORKTREE = 'repo-1::/home/user/remote-checkout' -const TAB = 'tab-parked' -const LEAF = 'leaf-1' - -// Why a complete tab rather than a cast partial: the merge reads `generation` to decide local -// preservation, so a fixture missing it would make the control pass for the wrong reason. -function terminalTab(overrides: Partial = {}): TerminalTab { - return { - id: TAB, - ptyId: null, - worktreeId: WORKTREE, - title: TAB, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0, - generation: 1, - ...overrides - } -} - -function layout(overrides: Partial = {}): TerminalLayoutSnapshot { - return { - root: { type: 'leaf', leafId: LEAF }, - activeLeafId: LEAF, - expandedLeafId: null, - ...overrides - } -} - -function sessionState(overrides: Partial = {}): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-1', - activeWorktreeId: WORKTREE, - activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE), - activeTabId: TAB, - ...overrides - } -} - -function merge( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - liveTabs: AppState['tabsByWorktree'] = {} -): WorkspaceSessionState { - return mergeDirectSshRemoteWorkspaceSession( - current, - remote, - new Set([WORKTREE]), - liveTabs, - new Set() - ) -} - -describe('direct-SSH reconnect merge: parked scrollback', () => { - it('keeps the park capture when the host copy predates it and no generation changed', () => { - const tab = terminalTab() - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [tab] }, - terminalLayoutsByTabId: { - [TAB]: layout({ buffersByLeafId: { [LEAF]: 'parked-scrollback' } }) - } - }) - // Same generation on both sides is the whole point: nothing marks this tab as locally newer. - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [tab] }, - terminalLayoutsByTabId: { [TAB]: layout() } - }) - - const merged = merge(current, remote, { [WORKTREE]: [tab] }) - - expect(merged.terminalLayoutsByTabId[TAB]?.buffersByLeafId).toEqual({ - [LEAF]: 'parked-scrollback' - }) - }) - - it('still lets the host own the layout structure it changed while we were away', () => { - const tab = terminalTab() - const splitRoot: TerminalLayoutSnapshot['root'] = { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF }, - second: { type: 'leaf', leafId: 'leaf-2' } - } - const current = sessionState({ - tabsByWorktree: { [WORKTREE]: [tab] }, - terminalLayoutsByTabId: { - [TAB]: layout({ buffersByLeafId: { [LEAF]: 'parked-scrollback' } }) - } - }) - const remote = sessionState({ - tabsByWorktree: { [WORKTREE]: [tab] }, - terminalLayoutsByTabId: { [TAB]: layout({ root: splitRoot }) } - }) - - const merged = merge(current, remote, { [WORKTREE]: [tab] }) - - expect(merged.terminalLayoutsByTabId[TAB]?.root).toEqual(splitRoot) - expect(merged.terminalLayoutsByTabId[TAB]?.buffersByLeafId).toEqual({ - [LEAF]: 'parked-scrollback' - }) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts deleted file mode 100644 index f02d7b98d1f1..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ /dev/null @@ -1,376 +0,0 @@ -import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - hasClosedTerminalTabRecord, - type ClosedTerminalTabTombstonesByTabId -} from '../../../shared/closed-terminal-tab-tombstones' -import type { ExecutionHostId } from '../../../shared/execution-host' -import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { splitWorktreeId } from '../../../shared/worktree/id' -import { retainLocalScrollbackInRemoteLayout } from '@/components/terminal-pane/remote-layout-scrollback-retention' -import { - getWorktreeIdFromHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' -import type { AppState } from '../store/types' - -function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab { - const preserved = { - ...remote, - generation: local.generation, - ptyId: local.ptyId, - // Why: the recovery ledger is client-local and travels with generation — - // a remote snapshot that dropped it would hand the tab a fresh remount - // allowance on every republication, which is the storm again (b5cfc6ca). - ...(local.recovery ? { recovery: local.recovery } : {}) - } - return local.pendingActivationSpawn - ? { ...preserved, pendingActivationSpawn: local.pendingActivationSpawn } - : preserved -} - -export function mergeDirectSshRemoteWorkspaceSession( - current: WorkspaceSessionState, - remote: WorkspaceSessionState, - replaceWorktreeIds: ReadonlySet, - liveTabsByWorktree: AppState['tabsByWorktree'], - preserveLocalTerminalTabIds: ReadonlySet, - replaceExecutionHostId?: ExecutionHostId, - closedTabRecords?: ClosedTerminalTabTombstonesByTabId, - preserveLocalLayoutTabIds: ReadonlySet = new Set(), - /** True once the target hydrated: selection is per view, so a republish must not move it. */ - keepLocalSelection = false -): WorkspaceSessionState { - // Live tabs across the worktrees this snapshot replaces. Close-suppression consults it so a tab - // that is still live locally always beats its own tombstone. - // What actually keeps suppression from deleting a tab the user did not close — the earlier claim - // that "every use is inside replaceWorktreeIds" was wrong, since the tabsByWorktree pass below - // walks all of orderedWorktreeIds and the layout/session sweeps cover the whole remote maps: - // 1. closeTab strips the id from EVERY worktree row before recording the tombstone - // (terminal-tab-close.ts), so a tombstoned id is not live anywhere; - // 2. isSuppressedByClose matches the tombstone's own worktreeId, so it cannot reach another - // workspace's tab even if an id somehow recurred; - // 3. only a tab close writes a record; nothing infers one from a tab's absence. - // A final sweep of suppression over the assembled tabsByWorktree — including worktrees - // omitTargetWorktrees passes through verbatim — is still deliberately absent. - const currentTabsById = new Map( - [...replaceWorktreeIds] - .flatMap((worktreeId) => liveTabsByWorktree[worktreeId] ?? []) - .map((tab) => [tab.id, tab]) - ) - const locallyPreservedTabIds = new Set() - const localTabsFor = (worktreeId: string): TerminalTab[] => - liveTabsByWorktree[worktreeId] ?? current.tabsByWorktree[worktreeId] ?? [] - // Why presence and not length: an explicit empty row, with a close record for the worktree, is how - // a workspace the user emptied reads (initial-terminal.ts), and localTabsFor cannot tell it from an - // absent one. Admitting only non-empty rows dropped the key, which reads downstream as "never - // initialized" and seeds a fresh terminal on every reconnect. - const hasLocalTabsRow = (worktreeId: string): boolean => - Object.hasOwn(liveTabsByWorktree, worktreeId) || - Object.hasOwn(current.tabsByWorktree, worktreeId) - // Why the union and not just the remote keys: a host snapshot that has never been told about this - // worktree carries no entry for it at all, and iterating only its keys would drop every local tab - // through the omit below. - const mergedWorktreeIds = new Set([ - ...Object.keys(remote.tabsByWorktree), - ...[...replaceWorktreeIds].filter(hasLocalTabsRow) - ]) - // The active worktree is walked FIRST so that when one tab id is held locally under two of them, - // the copy that survives below is the one the user is looking at. That matches what - // resolveActiveTabOwnerWorktreeId already prefers when it has to pick an owner, so the merge and - // the repair agree instead of each choosing differently. - const orderedWorktreeIds = [...mergedWorktreeIds].sort( - (a, b) => Number(b === current.activeWorktreeId) - Number(a === current.activeWorktreeId) - ) - // Every id already emitted by an earlier worktree in THIS merge. Without it the host-unknown - // branch below only excludes ids the host knows, so a tab id that local state already holds under - // two worktrees is re-added under both — two panes sharing one entry in terminalLayoutsByTabId and - // one in remoteSessionIdsByTabId, which means one remote PTY, plus the unconvergeable activeTabId - // that active-tab-owner-worktree.ts exists to mitigate (React #185). - // - // The merge does not create that state; it used to destroy it, by deleting every local tab under a - // replaced worktree. Keeping live panes cost that accidental cure, so the guarantee is restored for - // exactly the branch below: a LOCALLY-HELD tab is never re-added under a worktree this walk has - // already emitted it in. - // - // Deliberately not phrased as "no id twice". The set is consulted only by the host-unknown filter; - // `reconciled` maps the host's own list verbatim, so a snapshot that lists one tab id under two - // worktrees still propagates both. That is pre-existing and unchanged — the old code mapped those - // same entries — and it is the host's own contradiction rather than one this function introduces. - // - // Deliberately not stronger than that. A worktree that is neither replaced nor named by the host is - // never walked — its tabs pass through from `current` verbatim — so a duplicate straddling that - // boundary still survives, and the only place to catch it is after both halves are assembled. - // Doing it there was tried and REVERTED: the tie-break has to pick a survivor, and every rule - // available at that point is wrong during a worktree-id change, which is the very thing that - // produces these duplicates. Preferring the active worktree keeps the OLD id's copy at the moment a - // rename lands, because the active worktree has not moved yet — which left the new worktree with no - // tabs, so its groups were never created, and remote-workspace-snapshot-duplicate-tab-repair.test - // .ts caught it. A duplicate is survivable and already mitigated by active-tab-owner-worktree.ts; - // deleting the tabs of the worktree the user is about to land in is not. - const emittedTabIds = new Set() - const remoteKnownTabIds = new Set( - Object.values(remote.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id)) - ) - // Why sessions and not just tab ids: the host can carry the same agent session under a NEW tab id, - // and keeping the local tab as well would put one launched agent on the screen twice. The session - // id is the identity that survives a tab-id change, so a session the host already lists means the - // local tab is a stale alias rather than something the host has never seen. - const remoteKnownSessionIds = new Set( - Object.entries(remote.remoteSessionIdsByTabId ?? {}) - .filter(([tabId]) => remoteKnownTabIds.has(tabId)) - .map(([, sessionId]) => sessionId) - ) - // The other half of the trade below. Absence still cannot say "closed", so main's close record - // says it instead. Read only here: main alone writes records, and nothing acknowledges them away. - // Scoped to the record's own worktree, so suppression cannot reach another workspace's tab. - // Why a live local tab still overrides its record: an id that is live here means the record is - // stale, not a revival. Deleting a live pane is the one outcome this function exists to avoid. - const isSuppressedByClose = (tabId: string, worktreeId: string): boolean => - hasClosedTerminalTabRecord(closedTabRecords, tabId, worktreeId) && !currentTabsById.has(tabId) - // Ids this merge actually suppressed, recorded as it walks the worktrees. The layout and - // session-id sweeps below have no worktree in scope, so they consult decisions already made - // rather than re-deriving one without the scope that makes it safe. - const suppressedTabIds = new Set() - const tabsByWorktree = Object.fromEntries( - orderedWorktreeIds.map((worktreeId) => { - const remoteTabs = remote.tabsByWorktree[worktreeId] ?? [] - const reconciled = remoteTabs - .filter((tab) => { - if (!isSuppressedByClose(tab.id, worktreeId)) { - return true - } - suppressedTabIds.add(tab.id) - return false - }) - .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) - }) - for (const tab of reconciled) { - emittedTabIds.add(tab.id) - } - if (!replaceWorktreeIds.has(worktreeId)) { - return [worktreeId, reconciled] - } - // Why: the host is authoritative for what it knows, not for what it has never been told. A tab - // created locally whose upload was still pending is absent from the snapshot for the same - // reason it is new — deleting it here loses a live pane and the process running in it. - // - // The trade this accepts: absence cannot distinguish "never uploaded" from "closed by another - // client on this host", so a tab closed elsewhere survives here until that client's snapshot - // is applied. Closing on THIS client removes it from local state, so the common case is - // unaffected. Keeping a tab a moment too long is recoverable — the user closes it — while - // deleting a live one is not, and that is the failure this branch exists to prevent. - // Why the id is checked against EVERY worktree and not just this one: the same tab id can be - // owned by two worktrees while a rename settles, and re-adding it here would keep recreating - // the duplicate the repair pass is trying to converge. A host that knows the id anywhere has - // been told about the tab, so it is not host-unknown. - const hostUnknown = localTabsFor(worktreeId).filter((tab) => { - if (remoteKnownTabIds.has(tab.id) || emittedTabIds.has(tab.id)) { - return false - } - if (isSuppressedByClose(tab.id, worktreeId)) { - suppressedTabIds.add(tab.id) - return false - } - const localSessionId = current.remoteSessionIdsByTabId?.[tab.id] - return localSessionId == null || !remoteKnownSessionIds.has(localSessionId) - }) - for (const tab of hostUnknown) { - // Keeps the tab's layout and remote session id from being swept with the replaced ids below. - locallyPreservedTabIds.add(tab.id) - emittedTabIds.add(tab.id) - } - return [worktreeId, [...reconciled, ...hostUnknown]] - }) - ) - 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 omitTargetVisitRecency = ( - record: Record | undefined - ): Record => - Object.fromEntries( - Object.entries(record ?? {}).filter(([key]) => { - const worktreeId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key - if (!replaceWorktreeIds.has(worktreeId)) { - return true - } - // A direct SSH snapshot replaces one host's row. Legacy bare keys - // remain because they may be the only recency evidence for a sibling - // host; a qualified key is removed only for the target host. - return Boolean( - replaceExecutionHostId && - (!isWorktreeHostIdentity(key) || - key.slice(0, key.indexOf('|')) !== replaceExecutionHostId) - ) - }) - ) - const preservedLayoutTabIds = new Set([...locallyPreservedTabIds, ...preserveLocalLayoutTabIds]) - const terminalLayoutsByTabId = { - ...Object.fromEntries( - Object.entries(current.terminalLayoutsByTabId).filter( - ([tabId]) => !replacedTabIds.has(tabId) || preservedLayoutTabIds.has(tabId) - ) - ), - ...Object.fromEntries( - Object.entries(remote.terminalLayoutsByTabId) - .filter(([tabId]) => !preservedLayoutTabIds.has(tabId) && !suppressedTabIds.has(tabId)) - // Why: this replace is wholesale, and a park capture does not bump tab.generation, so a - // just-parked tab is not locally preserved and the only client-side copy of its remote - // scrollback would go with its layout. Structure stays the host's. - .map(([tabId, layout]) => [ - tabId, - retainLocalScrollbackInRemoteLayout(current.terminalLayoutsByTabId[tabId], layout) - ]) - ) - } - const activeOutsideTarget = - current.activeWorktreeId != null && !replaceWorktreeIds.has(current.activeWorktreeId) - // Why this is narrow: a null from the host is not always missing information. A null activeTabId - // is a deliberate deselect that arms the duplicate-tab repair, so it is honoured verbatim below. - // A null activeWorktreeId is different — it is what a snapshot carries when the host never named - // the workspace or its path did not resolve to a local id, and taking that literally drops the - // user onto the home screen while their terminals keep running. So it is only overridden when the - // workspace they are standing in demonstrably still exists in the merged result. - // Why presence and not length, the same reading `hasLocalTabsRow` above already gives: a - // workspace the user emptied keeps an explicit empty row, so it still exists in the merged - // result. Counting rows sent them to the home screen for having closed their last tab. - const localActiveWorkspaceSurvives = - current.activeWorktreeId != null && - replaceWorktreeIds.has(current.activeWorktreeId) && - Object.hasOwn(tabsByWorktree, current.activeWorktreeId) - const preservedActiveWorktreeId = localActiveWorkspaceSurvives ? current.activeWorktreeId : null - // The three active-* fields have to describe ONE workspace, so they are all derived from whichever - // worktree wins rather than each choosing a source. Taking the repo from the host while the - // worktree came from local state left the pair disagreeing — and precisely in the case the - // preservation exists for, since "the host named no worktree" is exactly when it can still name a - // repo. - // Why: every host republish carries the last uploaded selection, so after the first hydrate a - // title change while an agent works would otherwise steal focus back (#19697, #20938). - // The home screen is not kept: hydration reads a null worktree as "restore the repo's default". - const keepsLocalView = activeOutsideTarget || (keepLocalSelection && localActiveWorkspaceSurvives) - const keepsLocalWorkspace = - !keepsLocalView && remote.activeWorktreeId == null && preservedActiveWorktreeId != null - const activeWorktreeId = keepsLocalView - ? current.activeWorktreeId - : (remote.activeWorktreeId ?? preservedActiveWorktreeId) - const namesMergedTab = (worktreeId: string, tabId: string | null | undefined): tabId is string => - tabId != null && (tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) - const activeTabIdByWorktree = { - // Why the local entry survives: same rule one level down. A host that names no active tab for - // the target reopens the workspace on whatever sorts first, which is not where the user was. - ...omitTargetWorktrees(current.activeTabIdByWorktree), - ...Object.fromEntries( - [...replaceWorktreeIds].flatMap((worktreeId) => { - const localActiveTabId = current.activeTabIdByWorktree?.[worktreeId] - return localActiveTabId == null ? [] : [[worktreeId, localActiveTabId] as const] - }) - ), - // Why a suppressed id is left in place here: hydration validates both active-tab pointers - // against the tab rows it just built and nulls anything they no longer name, so nulling twice - // would only add a second rule that has to stay in step with that one. - ...remote.activeTabIdByWorktree, - // The host's pointer is only a fallback for a local one that no longer names a tab. - ...Object.fromEntries( - [...replaceWorktreeIds].flatMap((worktreeId) => { - const localActiveTabId = current.activeTabIdByWorktree?.[worktreeId] - return keepLocalSelection && namesMergedTab(worktreeId, localActiveTabId) - ? [[worktreeId, localActiveTabId] as const] - : [] - }) - ) - } - const localActiveTabStands = - activeOutsideTarget || - current.activeWorktreeId == null || - namesMergedTab(current.activeWorktreeId, current.activeTabId) - const fallbackActiveTabId = - current.activeWorktreeId == null - ? null - : (activeTabIdByWorktree[current.activeWorktreeId] ?? null) - return { - ...current, - activeRepoId: - keepsLocalView || keepsLocalWorkspace ? current.activeRepoId : remote.activeRepoId, - activeWorktreeId, - activeWorkspaceKey: keepsLocalView - ? current.activeWorkspaceKey - : activeWorktreeId - ? worktreeWorkspaceKey(activeWorktreeId) - : null, - activeTabId: !keepsLocalView - ? remote.activeTabId - : localActiveTabStands - ? current.activeTabId - : fallbackActiveTabId, - tabsByWorktree: { - ...omitTargetWorktrees(current.tabsByWorktree), - ...tabsByWorktree - }, - terminalLayoutsByTabId, - activeWorktreeIdsOnShutdown: [ - ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)), - ...(remote.activeWorktreeIdsOnShutdown ?? []) - ], - 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) && !suppressedTabIds.has(tabId) - ) - ) - }, - lastVisitedAtByWorktreeId: { - ...omitTargetVisitRecency(current.lastVisitedAtByWorktreeId), - ...remote.lastVisitedAtByWorktreeId - }, - defaultTerminalTabsAppliedByWorktreeId: { - // Why no omit here: the marker is write-once and is the only guard on applyDefaultTerminalTabs, - // so a snapshot that omits it is a host that was never told rather than one reporting the tabs - // were never applied — omitting the local entry re-applies the whole template over the user's - // tabs. Removal is the worktree-teardown path's job, not a reconnect's. - ...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-session-readiness.ts b/src/renderer/src/hooks/remote-workspace-session-readiness.ts deleted file mode 100644 index 6b0e5a3d33f4..000000000000 --- a/src/renderer/src/hooks/remote-workspace-session-readiness.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { StoreApi } from 'zustand' -import type { AppState } from '../store/types' - -const WORKSPACE_HYDRATION_TIMEOUT_MS = 10_000 -const WORKSPACE_HYDRATION_POLL_MS = 100 - -function waitForNextReadinessCheck(signal?: AbortSignal): Promise { - if (signal?.aborted) { - return Promise.resolve(false) - } - return new Promise((resolve) => { - let timer: ReturnType | null = null - let settled = false - const finish = (shouldContinue: boolean): void => { - if (settled) { - return - } - settled = true - if (timer !== null) { - clearTimeout(timer) - } - signal?.removeEventListener('abort', onAbort) - resolve(shouldContinue) - } - const onAbort = (): void => finish(false) - timer = setTimeout(() => finish(true), WORKSPACE_HYDRATION_POLL_MS) - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) { - finish(false) - } - }) -} - -export async function waitForRemoteWorkspaceSessionReady( - store: Pick, 'getState'>, - signal?: AbortSignal -): Promise { - const deadline = Date.now() + WORKSPACE_HYDRATION_TIMEOUT_MS - while (!signal?.aborted && Date.now() < deadline) { - if (store.getState().workspaceSessionReady) { - return true - } - if (!(await waitForNextReadinessCheck(signal))) { - return false - } - } - return !signal?.aborted && store.getState().workspaceSessionReady -} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts deleted file mode 100644 index 020d7d43c923..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Drives the real apply path with the real session writer wired exactly as the app wires it. - * - * The write gate closes for the whole apply plus a 1s suppression tail that expires on a wall - * clock — no store update follows it. So a tab the user closes on an unrelated target during - * target A's reconnect has to be held and written when the tail ends, or the close and its - * tombstone are lost until the next unrelated mutation (a crash or kill in between loses both). - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import { toAppSshPtyId } from '../../../shared/ssh-pty-id' -import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' -import { - createSessionWriteSubscriber, - type WorkspaceSessionWrite -} from '../lib/session-write-subscriber' -import { - applyDirectSshRemoteWorkspaceSnapshot, - isDirectSshRemoteWorkspaceApplyInProgress, - onDirectSshRemoteWorkspaceApplyWindowClosed -} from './remote-workspace-snapshot-apply' -import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } -})) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) - } -}) - -const TARGET_A = 'ssh-target-a' -const PATH_A = '/srv/proj/target-a' -const WORKTREE_A = `repoA::${PATH_A}` -const WORKTREE_B = 'repoB::/srv/other/target-b' - -const authority: DirectSshAuthority = { - targetId: TARGET_A, - providerEpoch: 'provider-epoch-1' as SshProviderEpoch, - connectionGeneration: 1 -} - -type TestStore = ReturnType - -function token(snapshotRevision: number): DirectSshSnapshotApplyToken { - return { - authority, - catalogRevision: 0, - repoFingerprint: 'fp', - authorityRequirement: 'required', - snapshotRevision, - outcome: 'complete' - } -} - -function snapshot(revision: number): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken: `observation-${revision}`, - session: { - activeWorktreePath: PATH_A, - activeTabId: 'tab-a', - tabsByWorktreePath: { - [PATH_A]: [ - { - id: 'tab-a', - worktreePath: PATH_A, - ptyId: 'pty-tab-a', - title: 'Terminal 1', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: { [PATH_A]: 'tab-a' }, - remoteSessionIdsByTabId: { 'tab-a': 'pty-tab-a' }, - lastVisitedAtByWorktreePath: { [PATH_A]: revision }, - defaultTerminalTabsAppliedByWorktreePath: { [PATH_A]: true } - } - } satisfies RemoteWorkspaceObservedSnapshot -} - -function seedCatalog(store: TestStore): void { - store.setState({ - worktreesByRepo: { - repoA: [ - makeWorktree({ - id: WORKTREE_A, - repoId: 'repoA', - path: PATH_A, - hostId: `ssh:${TARGET_A}` - } as never) - ] - }, - repos: [ - { - id: 'repoA', - path: '/srv/proj', - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_A - } as never - ], - reconnectPersistedTerminals: (async () => {}) as never, - markRemoteWorkspaceHydrated: (() => {}) as never, - setRemoteWorkspaceSyncStatus: (() => {}) as never - }) -} - -/** A second target's tab the user closes while target A is mid-apply. */ -function closeUnrelatedTargetTab(store: TestStore): void { - store.setState({ - tabsByWorktree: { ...store.getState().tabsByWorktree, [WORKTREE_B]: [] }, - closedTerminalTabTombstonesByTabId: { - 'tab-b': { closedAt: Date.now(), worktreeId: WORKTREE_B } - } - }) -} - -/** Everything here is timer-driven, so fake timers make the tail deterministic and instant. */ -describe('session writes deferred by a direct-SSH apply', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - async function applyTargetA( - store: TestStore, - onApplied: () => void, - remote = snapshot(1) - ): Promise { - const result = await applyDirectSshRemoteWorkspaceSnapshot({ - store, - snapshot: remote, - token: token(remote.revision), - arrival: 1, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - // Runs inside the apply, which is when the write gate is shut. - finalizeHydratedTerminals: () => { - onApplied() - return 0 - } - }) - expect(result).toBe('applied') - } - - function seedBothTargets(store: TestStore): void { - seedCatalog(store) - store.setState({ - tabsByWorktree: { - [WORKTREE_B]: [ - { - id: 'tab-b', - ptyId: 'pty-tab-b', - worktreeId: WORKTREE_B, - title: 'Other host', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } as never - ] - } - }) - } - - it('lands an unrelated target\u2019s tab close once the suppression tail expires', async () => { - const store = createTestStore() - seedBothTargets(store) - - const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() - const cleanup = createSessionWriteSubscriber({ - store, - persist, - shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), - subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed - }) - try { - store.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) - vi.advanceTimersByTime(200) - expect(persist).toHaveBeenCalled() - persist.mockClear() - - await applyTargetA(store, () => closeUnrelatedTargetTab(store)) - - expect( - isDirectSshRemoteWorkspaceApplyInProgress(), - 'the suppression tail should still be holding writes here' - ).toBe(true) - expect(persist).not.toHaveBeenCalled() - - // Tail (1000ms) + the notice's 1ms margin + the writer's 150ms debounce. - vi.advanceTimersByTime(1_200) - - expect(persist, 'the close made during the apply never reached disk').toHaveBeenCalledTimes(1) - const patch = persist.mock.calls[0][0].patch - expect(patch.tabsByWorktree?.[WORKTREE_B]).toEqual([]) - expect(patch.tabsByWorktree?.[WORKTREE_A]).toHaveLength(1) - } finally { - cleanup() - } - }) - - it.each(['missing', 'outdated'] as const)( - 'preserves a local split awaiting upload when reconnect has a %s layout', - async (remoteLayout) => { - const store = createTestStore() - seedCatalog(store) - const first = '11111111-1111-4111-8111-111111111111' - const second = '22222222-2222-4222-8222-222222222222' - const original: TerminalLayoutSnapshot = { - root: { type: 'leaf', leafId: first }, - activeLeafId: first, - expandedLeafId: null, - ptyIdsByLeafId: { [first]: 'pty-tab-a' } - } - const initial = snapshot(1) - initial.session.terminalLayoutsByTabId = { 'tab-a': original } - const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() - const cleanup = createSessionWriteSubscriber({ - store, - persist, - shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), - subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed - }) - try { - store.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) - await applyTargetA(store, () => {}, initial) - store.getState().updateTabPtyId('tab-a', toAppSshPtyId(TARGET_A, 'pty-tab-a')) - vi.advanceTimersByTime(1_200) - persist.mockClear() - const generation = store.getState().tabsByWorktree[WORKTREE_A][0].generation - const split: TerminalLayoutSnapshot = { - ...original, - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: first }, - second: { type: 'leaf', leafId: second } - }, - activeLeafId: second, - ptyIdsByLeafId: { - [first]: toAppSshPtyId(TARGET_A, 'pty-tab-a'), - [second]: toAppSshPtyId(TARGET_A, 'pty-split') - } - } - store.getState().setTabLayout('tab-a', split) - expect(store.getState().tabsByWorktree[WORKTREE_A][0].generation).toBe(generation) - expect(persist).not.toHaveBeenCalled() - - store.getState().clearDirectSshTargetPtyBindings(TARGET_A) - expect(store.getState().directSshPaneRetryByTabId).toEqual({}) - expect(store.getState().directSshLivePtyBindingByTabId).toEqual({}) - expect(store.getState().tabsByWorktree[WORKTREE_A][0].pendingActivationSpawn).toBeFalsy() - const stale = snapshot(1) - stale.session.tabsByWorktreePath[PATH_A][0].generation = generation - if (remoteLayout === 'outdated') { - stale.session.terminalLayoutsByTabId = { 'tab-a': original } - } - await applyTargetA(store, () => {}, stale) - vi.advanceTimersByTime(1_200) - - const restored = store.getState().terminalLayoutsByTabId['tab-a'] - expect(restored?.root).toEqual(split.root) - expect(restored?.ptyIdsByLeafId).toEqual(split.ptyIdsByLeafId) - expect(persist).toHaveBeenCalledTimes(1) - expect(persist.mock.calls[0][0].patch.terminalLayoutsByTabId?.['tab-a']?.root).toEqual( - split.root - ) - } finally { - cleanup() - } - } - ) - - it('still wakes the deferred write when the wall clock steps back during the tail', async () => { - const store = createTestStore() - seedBothTargets(store) - const startedAt = Date.now() - - const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() - const cleanup = createSessionWriteSubscriber({ - store, - persist, - shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), - subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed - }) - try { - store.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) - vi.advanceTimersByTime(200) - persist.mockClear() - - await applyTargetA(store, () => closeUnrelatedTargetTab(store)) - - // The notice's delay is wall-clock arithmetic on a monotonic timer, so an NTP step back - // leaves the deadline in the future when it fires. It must wait, not give up. - vi.setSystemTime(startedAt - 5_000) - vi.advanceTimersByTime(1_200) - expect(persist, 'the notice fired before its own deadline').not.toHaveBeenCalled() - - vi.advanceTimersByTime(5_200) - - expect(persist, 'a clock step back stranded the deferred write').toHaveBeenCalledTimes(1) - expect(persist.mock.calls[0][0].patch.tabsByWorktree?.[WORKTREE_B]).toEqual([]) - } finally { - cleanup() - } - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts deleted file mode 100644 index 8267859d1755..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { toSshExecutionHostId } from '../../../shared/execution-host' -import { translate } from '@/i18n/i18n' -import { buildWorkspaceSessionPayload } from '../lib/workspace-session' -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' -import { - resolveDirectSshSnapshotWorktreeIds, - waitForSnapshotWorktreePlacement, - type RemoteWorkspaceSnapshotPlacementStore -} from './remote-workspace-snapshot-placement' - -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 -} - -const applyWindowCloseListeners = new Set<() => void>() - -/** - * Fires once after the write-suppression tail expires. Why: the tail ends on a wall clock with no - * store update behind it, so a session write deferred during an apply has nothing else to wake it. - */ -export function onDirectSshRemoteWorkspaceApplyWindowClosed(listener: () => void): () => void { - applyWindowCloseListeners.add(listener) - return () => { - applyWindowCloseListeners.delete(listener) - } -} - -/** - * One timer in flight per apply. It never polls: the tail deadline only moves forward, and each - * re-arm waits exactly the time still left on it, so the wait converges instead of spinning. - */ -function scheduleApplyWindowClosedNotice(): void { - // +1ms because the gate compares `Date.now() < suppressUntil`; fire strictly past the deadline. - const delayMs = Math.max(0, snapshotWriteSuppressUntil - Date.now()) + 1 - setTimeout(() => { - // An apply still in flight schedules the next notice from its own `finally`. - if (snapshotApplyDepth > 0) { - return - } - // Why re-arm rather than return: `delayMs` is wall-clock arithmetic handed to a monotonic - // timer, so a clock step back (NTP) can leave the deadline in the future when this fires. - // Dropping the notice there would strand every write deferred in this window. - if (Date.now() < snapshotWriteSuppressUntil) { - scheduleApplyWindowClosedNotice() - return - } - // Safe to iterate live: Set iteration tolerates a listener unsubscribing itself. - for (const listener of applyWindowCloseListeners) { - listener() - } - }, delayMs) -} - -type RemoteWorkspaceSnapshotApplyInput = { - store: RemoteWorkspaceSnapshotPlacementStore - snapshot: RemoteWorkspaceObservedSnapshot - token: DirectSshSnapshotApplyToken - arrival: number - arrivalSignal?: AbortSignal - isArrivalCurrent: (targetId: string, arrival: number) => boolean - isPreparationTokenCurrent: (token: DirectSshPreparationToken) => boolean - waitForWorkspaceSessionReady: (signal?: AbortSignal) => Promise - finalizeHydratedTerminals: (authority: DirectSshAuthority) => number - /** - * Host paths still carrying terminal tabs when this apply gave up placing them. `unverifiable`, - * never proof the rows are not ours, so the caller owns getting back to a placed picture — this - * apply itself has no way back once the bounded in-apply wait expires. - */ - onUnplacedTabWorktreePaths?: (worktreePaths: readonly string[]) => void -} - -export type RemoteWorkspaceSnapshotApplyResult = 'applied' | 'stale' | 'failed' - -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, - arrivalSignal, - isArrivalCurrent, - isPreparationTokenCurrent, - waitForWorkspaceSessionReady, - finalizeHydratedTerminals, - onUnplacedTabWorktreePaths -}: RemoteWorkspaceSnapshotApplyInput): Promise { - const { authority } = token - if (!isArrivalCurrent(authority.targetId, arrival)) { - return 'stale' - } - if ( - !isPreparationTokenCurrent(token) || - !admitDirectSshSnapshotApplyToken(token, authority, snapshot.revision) - ) { - return 'stale' - } - if (!(await waitForWorkspaceSessionReady(arrivalSignal))) { - 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 'failed' - } - let state = store.getState() - let worktreeIds = resolveDirectSshSnapshotWorktreeIds(state, authority) - let unplacedTabWorktreePaths: string[] = [] - let remoteSession = importRemoteWorkspaceSession(snapshot.session, { - resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds), - executionHostId: toSshExecutionHostId(authority.targetId), - onUnplacedTerminalTabs: (worktreePath) => unplacedTabWorktreePaths.push(worktreePath) - }) - if (unplacedTabWorktreePaths.length > 0) { - await waitForSnapshotWorktreePlacement( - store, - authority, - unplacedTabWorktreePaths, - () => isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token), - arrivalSignal - ) - // The placement wait can last ten seconds; merge against the state that exists when it ends, - // even when the path never became placeable. Otherwise a tab created while waiting is omitted - // from the stale session payload and can be replaced by this snapshot. - state = store.getState() - worktreeIds = resolveDirectSshSnapshotWorktreeIds(state, authority) - unplacedTabWorktreePaths = [] - remoteSession = importRemoteWorkspaceSession(snapshot.session, { - resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds), - executionHostId: toSshExecutionHostId(authority.targetId), - onUnplacedTerminalTabs: (worktreePath) => unplacedTabWorktreePaths.push(worktreePath) - }) - } - const merged = mergeDirectSshRemoteWorkspaceSession( - buildWorkspaceSessionPayload(state), - remoteSession, - worktreeIds, - state.tabsByWorktree, - currentRecoveryTabIds(state, authority, worktreeIds), - toSshExecutionHostId(authority.targetId), - state.closedTerminalTabTombstonesByTabId, - new Set( - [...worktreeIds].flatMap((id) => - (state.tabsByWorktree[id] ?? []) - .filter( - (tab) => - state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId - ) - .map((tab) => tab.id) - ) - ), - state.remoteWorkspaceHydratedTargetIds.has(authority.targetId) - ) - if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { - return 'stale' - } - const hasUnplacedTerminalTabs = unplacedTabWorktreePaths.length > 0 - onUnplacedTabWorktreePaths?.([...unplacedTabWorktreePaths]) - 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. - if (!hasUnplacedTerminalTabs) { - currentStore.markRemoteWorkspaceHydrated(authority.targetId) - currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'synced', - direction: 'pull', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken, - message: translate('auto.hooks.useIpcEvents.4f78ba5885', 'Workspace synced'), - lastSyncedAt: Date.now() - }) - } else { - // The host listed tabs on paths this client cannot place, so adopting zero of them is not - // the host's picture. `conflict` is the phase that says exactly that, and it is load-bearing - // twice over: - // - use-app-session-persistence.ts filters conflicted targets out of uploads, and an - // upload is a `replace-session` patch (remote-workspace-relay-sync.ts) that wholesale - // replaces the host snapshot - it would delete the very tabs we failed to adopt; - // - workspace-terminal-host-authority.ts treats `offline`/`error` on an un-hydrated target - // as `none`, its bounded floor, which authorises seeding AND sleeping-agent resume. - // `conflict` is deliberately not in that set: the unplaced paths stay `unverifiable`, - // and only the worktrees this apply did place read `none`. - // Hydration is cleared, not merely withheld: the set is add-only, so a target that synced - // cleanly before would otherwise keep uploading from this incomplete picture (STA-3593). - currentStore.clearRemoteWorkspaceHydrated(authority.targetId) - currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'conflict', - direction: 'pull', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken, - unplacedTabWorktreePaths: [...unplacedTabWorktreePaths] - }) - } - 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 - scheduleApplyWindowClosedNotice() - } - return 'applied' -} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts deleted file mode 100644 index b6cb5a4c8389..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { expect, it } from 'vitest' -import { createRemoteWorkspaceSnapshotArrivalCoordinator } from './remote-workspace-snapshot-arrival-coordinator' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -it('releases completed generations across repeated target churn', async () => { - const coordinator = createRemoteWorkspaceSnapshotArrivalCoordinator() - const arrivals: number[] = [] - - for (let index = 0; index < 128; index += 1) { - const targetId = `target-${index % 8}` - await coordinator.run(targetId, async (arrival) => { - arrivals.push(arrival) - }) - expect(coordinator.isCurrent(targetId, 1)).toBe(false) - } - - expect(new Set(arrivals)).toEqual(new Set([1])) -}) - -it('does not reuse a generation while an aborted operation is still settling', async () => { - const coordinator = createRemoteWorkspaceSnapshotArrivalCoordinator() - const firstCanFinish = deferred() - const thirdCanFinish = deferred() - const arrivals: number[] = [] - let firstStillCurrent = true - - const first = coordinator.run('target-a', async (arrival) => { - arrivals.push(arrival) - await firstCanFinish.promise - firstStillCurrent = coordinator.isCurrent('target-a', arrival) - }) - await coordinator.run('target-a', async (arrival) => { - arrivals.push(arrival) - }) - const third = coordinator.run('target-a', async (arrival) => { - arrivals.push(arrival) - await thirdCanFinish.promise - }) - - expect(arrivals).toEqual([1, 2, 3]) - - firstCanFinish.resolve() - await first - expect(firstStillCurrent).toBe(false) - - thirdCanFinish.resolve() - await third - expect(coordinator.isCurrent('target-a', 3)).toBe(false) -}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.ts b/src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.ts deleted file mode 100644 index 8fed9ef6ebb2..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.ts +++ /dev/null @@ -1,60 +0,0 @@ -type SnapshotArrivalOperation = (arrival: number, signal: AbortSignal) => Promise - -type SnapshotArrivalTargetState = { - arrival: number - activeOperations: number - controller: AbortController | null -} - -export type RemoteWorkspaceSnapshotArrivalCoordinator = { - isCurrent: (targetId: string, arrival: number) => boolean - run: (targetId: string, operation: SnapshotArrivalOperation) => Promise - stop: () => void -} - -export function createRemoteWorkspaceSnapshotArrivalCoordinator(): RemoteWorkspaceSnapshotArrivalCoordinator { - const stateByTarget = new Map() - let stopped = false - - const isCurrent = (targetId: string, arrival: number): boolean => - !stopped && stateByTarget.get(targetId)?.arrival === arrival - - const run = async (targetId: string, operation: SnapshotArrivalOperation): Promise => { - if (stopped) { - return - } - const state = stateByTarget.get(targetId) ?? { - arrival: 0, - activeOperations: 0, - controller: null - } - state.arrival += 1 - state.activeOperations += 1 - state.controller?.abort() - const controller = new AbortController() - state.controller = controller - stateByTarget.set(targetId, state) - const arrival = state.arrival - try { - await operation(arrival, controller.signal) - } finally { - state.activeOperations -= 1 - if (state.controller === controller) { - state.controller = null - } - if (state.activeOperations === 0 && stateByTarget.get(targetId) === state) { - stateByTarget.delete(targetId) - } - } - } - - const stop = (): void => { - stopped = true - for (const state of stateByTarget.values()) { - state.controller?.abort() - } - stateByTarget.clear() - } - - return { isCurrent, run, stop } -} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts deleted file mode 100644 index b9e70bf86c2d..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * A direct-SSH snapshot can retain the same tab under old and new worktree IDs - * after a path or repo-ID change. This exercises that hydration path and proves - * active-tab repair converges; it deliberately does not remove the duplicate or - * reproduce React's scheduler-level #185 throw. See PR #11950 for that evidence. - */ -import { describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' -import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' -import { repairActiveTerminalTab } from '../components/terminal/use-active-terminal-repair' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const TARGET_ID = 'ssh-target-1' -const OLD_PATH = '/srv/proj/wt' -const NEW_PATH = '/srv/proj/wt-renamed' -const OLD_ID = `repoA::${OLD_PATH}` -const NEW_ID = `repoA::${NEW_PATH}` -// Why a cap and not a while(true): on unfixed code this cycle never terminates. -const MAX_REPAIR_PASSES = 200 - -const authority: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'provider-epoch-1' as SshProviderEpoch, - connectionGeneration: 1 -} - -function token(snapshotRevision: number): DirectSshSnapshotApplyToken { - return { - authority, - catalogRevision: 0, - repoFingerprint: 'fp', - authorityRequirement: 'required', - snapshotRevision, - outcome: 'complete' - } -} - -function snapshot( - revision: number, - worktreePath: string, - tabIds: readonly string[], - activeTabId: string | null -): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken: `observation-${revision}`, - session: { - activeWorktreePath: worktreePath, - activeTabId, - tabsByWorktreePath: { - [worktreePath]: tabIds.map((tabId, index) => ({ - id: tabId, - worktreePath, - ptyId: `pty-${tabId}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: { [worktreePath]: activeTabId }, - remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), - lastVisitedAtByWorktreePath: { [worktreePath]: revision }, - defaultTerminalTabsAppliedByWorktreePath: { [worktreePath]: true } - } - } satisfies RemoteWorkspaceObservedSnapshot -} - -type TestStore = ReturnType - -async function applySnapshot( - store: TestStore, - snap: RemoteWorkspaceObservedSnapshot -): Promise { - await applyDirectSshRemoteWorkspaceSnapshot({ - store, - snapshot: snap, - token: token(snap.revision), - arrival: 1, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 - }) -} - -function worktreeIdsOwningTab(store: TestStore, tabId: string): string[] { - return Object.entries(store.getState().tabsByWorktree) - .filter(([, tabs]) => tabs.some((tab) => tab.id === tabId)) - .map(([worktreeId]) => worktreeId) -} - -function seedCatalog(store: TestStore, worktreePath: string): void { - store.setState({ - worktreesByRepo: { - repoA: [ - makeWorktree({ - id: `repoA::${worktreePath}`, - repoId: 'repoA', - path: worktreePath, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - }) -} - -/** - * One turn of the loop in Terminal.tsx's active-terminal repair effect: - * recompute the repaired id from live state, then activate it. Reports how many - * turns it took to stop and how often `activeTabIdByWorktree` — a declared dep - * of that effect, so a fresh identity re-runs it — was reallocated. - */ -function runRepairCycle(store: TestStore): { - converged: boolean - passes: number - depIdentityChurn: number -} { - let passes = 0 - let depIdentityChurn = 0 - for (; passes < MAX_REPAIR_PASSES; passes += 1) { - const live = store.getState() - const depsBefore = live.activeTabIdByWorktree - const repaired = repairActiveTerminalTab({ - activeTabType: 'terminal', - activeTabId: live.activeTabId, - activeTabIdByWorktree: live.activeTabIdByWorktree, - renderedActiveWorktreeId: live.activeWorktreeId, - setActiveTab: live.setActiveTab, - tabs: live.activeWorktreeId ? (live.tabsByWorktree[live.activeWorktreeId] ?? []) : [] - }) - if (!repaired) { - return { converged: true, passes, depIdentityChurn } - } - if (store.getState().activeTabIdByWorktree !== depsBefore) { - depIdentityChurn += 1 - } - } - return { converged: false, passes, depIdentityChurn } -} - -describe('direct-SSH snapshot apply, tab id owned by two worktrees', () => { - it('converges the active-terminal repair instead of re-running it forever', async () => { - const store = createTestStore() - - store.setState({ - repos: [ - { - id: 'repoA', - path: '/srv/proj', - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID - } as never - ], - // Load-bearing, do not drop: the IPC attach is the only thing stubbed, and - // it leaves behind exactly what a real reconnect leaves behind — one - // registered live PTY per tab. Without that the orphan sweep on the next - // worktree visit treats the duplicated tab as dead, cleans it up, and the - // bug evaporates before the repair effect ever sees it. - reconnectPersistedTerminals: (async () => { - const live = store.getState() - const registered: Record = { ...live.ptyIdsByTabId } - for (const tabs of Object.values(live.tabsByWorktree)) { - for (const tab of tabs) { - registered[tab.id] = [`pty-${tab.id}`] - } - } - store.setState({ ptyIdsByTabId: registered }) - }) as never, - markRemoteWorkspaceHydrated: (() => {}) as never, - setRemoteWorkspaceSyncStatus: (() => {}) as never - }) - - seedCatalog(store, OLD_PATH) - await applySnapshot(store, snapshot(1, OLD_PATH, ['tab-1', 'tab-2'], 'tab-1')) - store.getState().setActiveWorktree(OLD_ID) - - // The worktree is renamed on the host; the catalog re-detects it at the new - // path, so the worktree id changes while the tab ids do not. - seedCatalog(store, NEW_PATH) - await applySnapshot(store, snapshot(2, NEW_PATH, ['tab-1', 'tab-2'], 'tab-1')) - store.getState().setActiveWorktree(NEW_ID) - - // The remote deselects; importRemoteWorkspaceSession nulls an activeTabId it - // cannot find among the imported tabs, which is what arms the repair effect. - await applySnapshot(store, snapshot(3, NEW_PATH, ['tab-1', 'tab-2'], null)) - - // Why assert the precondition and not its removal: the fix stops the owner - // resolver being fooled by the duplicate, it does not remove the duplicate. - // Pinned so the test cannot pass vacuously if hydration stops producing one. - expect(worktreeIdsOwningTab(store, 'tab-1')).toEqual([OLD_ID, NEW_ID]) - expect(store.getState().activeTabId).toBeNull() - - const repair = runRepairCycle(store) - - expect(repair.converged).toBe(true) - expect(repair.passes).toBeLessThanOrEqual(store.getState().tabsByWorktree[NEW_ID].length) - expect(store.getState().activeTabId).toBe('tab-1') - const activeGroupId = store.getState().activeGroupIdByWorktree[NEW_ID] - expect( - store.getState().groupsByWorktree[NEW_ID].find((group) => group.id === activeGroupId) - ?.activeTabId - ).toBe('tab-1') - - // The dep identity settles: re-running the effect body after convergence - // reallocates nothing, so the effect does not schedule itself again. - const settled = runRepairCycle(store) - expect(settled.depIdentityChurn).toBe(0) - expect(settled.passes).toBe(0) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts deleted file mode 100644 index 58a5a02a0bfa..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * A client that has never held a direct-SSH workspace — a re-added host, a cleared profile, a second - * machine — activates it before the host snapshot lands and seeds an initial terminal into that gap. - * The snapshot then arrives, the merge correctly keeps the locally-created tab it was never told - * about, and the union uploads as the new host truth. - * - * Scope, because it moved twice under investigation: an ordinary RESTART does not reach this, since - * local session state restores the workspace's tab row first. The trigger is a client with no local - * row for a workspace the host already owns (#15556 / STA-4908). - * - * Two oracles. Cardinality: the client must not add a tab the host did not name. Adoption: declining - * to seed must not cost the user the host's real tabs — an empty workspace is not an improvement on - * a wrong one. - */ -import { describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' -import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' -import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const TARGET_ID = 'ssh-target-1' -const PATH = '/srv/proj/bug-cats' -const WORKTREE_ID = `repoA::${PATH}` - -const authority: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'provider-epoch-1' as SshProviderEpoch, - connectionGeneration: 1 -} - -function token(snapshotRevision: number): DirectSshSnapshotApplyToken { - return { - authority, - catalogRevision: 0, - repoFingerprint: 'fp', - authorityRequirement: 'required', - snapshotRevision, - outcome: 'complete' - } -} - -function snapshot(revision: number, tabIds: readonly string[]): RemoteWorkspaceObservedSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken: `observation-${revision}`, - session: { - activeWorktreePath: PATH, - activeTabId: tabIds[0] ?? null, - tabsByWorktreePath: { - [PATH]: tabIds.map((tabId, index) => ({ - id: tabId, - worktreePath: PATH, - ptyId: `pty-${tabId}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: { [PATH]: tabIds[0] ?? null }, - remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), - lastVisitedAtByWorktreePath: { [PATH]: revision }, - defaultTerminalTabsAppliedByWorktreePath: { [PATH]: true } - } - } satisfies RemoteWorkspaceObservedSnapshot -} - -type TestStore = ReturnType - -async function applySnapshot( - store: TestStore, - snap: RemoteWorkspaceObservedSnapshot -): Promise { - await applyDirectSshRemoteWorkspaceSnapshot({ - store, - snapshot: snap, - token: token(snap.revision), - arrival: 1, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 - }) -} - -function seedCatalog(store: TestStore): void { - store.setState({ - worktreesByRepo: { - repoA: [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repoA', - path: PATH, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - }, - repos: [ - { - id: 'repoA', - path: '/srv/proj', - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID - } as never - ], - // Why: only the IPC-backed reconnect is stubbed; hydration bookkeeping stays real because it is - // the very signal under test. - reconnectPersistedTerminals: (async () => {}) as never, - setRemoteWorkspaceSyncStatus: (() => {}) as never - }) -} - -/** A relaunch: the workspace's tab rows and this session's host answers are both gone. */ -function simulateColdStart(store: TestStore): void { - const live = store.getState() - const { [WORKTREE_ID]: _tabs, ...tabsByWorktree } = live.tabsByWorktree - const { [WORKTREE_ID]: _unified, ...unifiedTabsByWorktree } = live.unifiedTabsByWorktree - store.setState({ - tabsByWorktree, - unifiedTabsByWorktree, - remoteWorkspaceHydratedTargetIds: new Set() - }) -} - -function terminalTabIds(store: TestStore): string[] { - return (store.getState().tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => tab.id) -} - -describe('a fresh client meeting a workspace the host already owns', () => { - it('holds one tab when the host still reports one tab', async () => { - const store = createTestStore() - seedCatalog(store) - - // Yesterday: the host owns exactly one terminal in this workspace. - await applySnapshot(store, snapshot(1, ['T1'])) - expect(terminalTabIds(store)).toEqual(['T1']) - - // This morning: the app restarts and activates the workspace before SSH has answered. - simulateColdStart(store) - ensureWorktreeHasInitialTerminal(store.getState(), WORKTREE_ID) - - // The host answers, still holding only T1. - await applySnapshot(store, snapshot(2, ['T1'])) - - expect( - terminalTabIds(store), - 'the launch seeded a terminal into the gap before the host answered' - ).toEqual(['T1']) - }) - - it('adopts every tab the host owns instead of replacing them with a seeded one', async () => { - const store = createTestStore() - seedCatalog(store) - - // A client with no local row for this workspace, activating it before the snapshot lands. - expect(store.getState().tabsByWorktree[WORKTREE_ID]).toBeUndefined() - ensureWorktreeHasInitialTerminal(store.getState(), WORKTREE_ID) - - await applySnapshot(store, snapshot(1, ['T1', 'T2', 'T3'])) - - expect( - terminalTabIds(store), - 'declining to seed must not also cost the user the tabs the host really owns' - ).toEqual(['T1', 'T2', 'T3']) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts deleted file mode 100644 index 69660e1e6ea4..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Reconnecting an SSH workspace must not delete local state the host has not been told about. - * - * Reported from a 60-second manual test: reconnect, and a second tab that was running `pnpm install` - * is gone while the app drops to the home screen. Both come from the same place — the host snapshot - * is applied as the whole truth for the reconnecting target, so a tab created locally but not yet - * uploaded has no branch that keeps it, and a snapshot that names no active worktree nulls the one - * the user is standing in. - * - * This drives the real apply path rather than the merge function alone, and it is deterministic: the - * end-to-end version of the same scenario only reproduces about one run in three, because the bug - * needs the tab to be created inside the debounced upload's suppression window. - */ -import { describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' -import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const TARGET_ID = 'ssh-target-1' -const PATH = '/srv/proj/bug-cats' -const WORKTREE_ID = `repoA::${PATH}` - -const authority: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'provider-epoch-1' as SshProviderEpoch, - connectionGeneration: 1 -} - -function token(snapshotRevision: number): DirectSshSnapshotApplyToken { - return { - authority, - catalogRevision: 0, - repoFingerprint: 'fp', - authorityRequirement: 'required', - snapshotRevision, - outcome: 'complete' - } -} - -function snapshot( - revision: number, - tabIds: readonly string[], - options: { activeWorktreePath?: string | null } = {} -): RemoteWorkspaceObservedSnapshot { - const activeWorktreePath = - options.activeWorktreePath === undefined ? PATH : options.activeWorktreePath - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken: `observation-${revision}`, - session: { - activeWorktreePath, - activeTabId: tabIds[0] ?? null, - tabsByWorktreePath: { - [PATH]: tabIds.map((tabId, index) => ({ - id: tabId, - worktreePath: PATH, - ptyId: `pty-${tabId}`, - title: `Terminal ${index + 1}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: index + 1 - })) - }, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: { [PATH]: tabIds[0] ?? null }, - remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])), - lastVisitedAtByWorktreePath: { [PATH]: revision }, - defaultTerminalTabsAppliedByWorktreePath: { [PATH]: true } - } - } satisfies RemoteWorkspaceObservedSnapshot -} - -type TestStore = ReturnType - -async function applySnapshot( - store: TestStore, - snap: RemoteWorkspaceObservedSnapshot -): Promise { - await applyDirectSshRemoteWorkspaceSnapshot({ - store, - snapshot: snap, - token: token(snap.revision), - arrival: 1, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 - }) -} - -function seedCatalog(store: TestStore): void { - store.setState({ - worktreesByRepo: { - repoA: [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repoA', - path: PATH, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - }, - repos: [ - { - id: 'repoA', - path: '/srv/proj', - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID - } as never - ], - reconnectPersistedTerminals: (async () => {}) as never, - markRemoteWorkspaceHydrated: (() => {}) as never, - setRemoteWorkspaceSyncStatus: (() => {}) as never - }) -} - -/** A tab the user created locally whose upload has not landed yet. */ -function addLocalTab(store: TestStore, tabId: string): void { - const live = store.getState() - store.setState({ - tabsByWorktree: { - ...live.tabsByWorktree, - [WORKTREE_ID]: [ - ...(live.tabsByWorktree[WORKTREE_ID] ?? []), - { - id: tabId, - worktreeId: WORKTREE_ID, - type: 'terminal', - title: tabId - } as never - ] - } - }) -} - -describe('direct-SSH snapshot apply keeps local state the host has not seen', () => { - it('keeps a tab created locally between snapshots', async () => { - const store = createTestStore() - seedCatalog(store) - await applySnapshot(store, snapshot(1, ['agent'])) - store.getState().setActiveWorktree(WORKTREE_ID) - - // The reported `setup` tab: created after the first snapshot, upload still pending, so the next - // snapshot the host sends still knows only about `agent`. - addLocalTab(store, 'setup') - await applySnapshot(store, snapshot(2, ['agent'])) - - const tabIds = (store.getState().tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => tab.id) - expect(tabIds, 'the reconnect deleted a tab the host had never been told about').toContain( - 'setup' - ) - expect(tabIds).toContain('agent') - }) - - it('leaves the user on their workspace when the snapshot names no active worktree', async () => { - const store = createTestStore() - seedCatalog(store) - await applySnapshot(store, snapshot(1, ['agent'])) - store.getState().setActiveWorktree(WORKTREE_ID) - expect(store.getState().activeWorktreeId).toBe(WORKTREE_ID) - - // A snapshot whose active path does not resolve to a known worktree. Taking that as "no active - // workspace" is what drops the user to the home screen mid-session. - await applySnapshot(store, snapshot(2, ['agent'], { activeWorktreePath: null })) - - expect( - store.getState().activeWorktreeId, - 'the reconnect dropped the user to the home screen' - ).toBe(WORKTREE_ID) - }) - - it('does not duplicate a tab across repeated snapshots', async () => { - const store = createTestStore() - seedCatalog(store) - await applySnapshot(store, snapshot(1, ['agent'])) - store.getState().setActiveWorktree(WORKTREE_ID) - addLocalTab(store, 'setup') - - await applySnapshot(store, snapshot(2, ['agent'])) - await applySnapshot(store, snapshot(3, ['agent'])) - - const tabIds = (store.getState().tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => tab.id) - expect(tabIds).toEqual([...new Set(tabIds)]) - expect(tabIds.filter((id) => id === 'setup')).toHaveLength(1) - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-placement.ts b/src/renderer/src/hooks/remote-workspace-snapshot-placement.ts deleted file mode 100644 index 29925baa4cbc..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-placement.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { StoreApi } from 'zustand' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { AppState } from '../store/types' -import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' -import { uniqueWorktreeIdByPath } from './remote-workspace-session-merge' - -const SNAPSHOT_WORKTREE_PLACEMENT_TIMEOUT_MS = 10_000 - -export type RemoteWorkspaceSnapshotPlacementStore = Pick, 'getState'> & - Partial, 'subscribe'>> - -export function resolveDirectSshSnapshotWorktreeIds( - state: AppState, - authority: DirectSshAuthority -): Set { - const expectedHostId = toSshExecutionHostId(authority.targetId) - const worktreeIds = new Set( - 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 - ) - // A host-qualified worktree can become placeable before duplicate repo rows reconcile. - for (const worktree of [ - ...Object.values(state.worktreesByRepo).flat(), - ...Object.values(state.detectedWorktreesByRepo).flatMap((result) => result.worktrees) - ]) { - if (worktree.hostId === expectedHostId) { - worktreeIds.add(worktree.id) - } - } - return worktreeIds -} - -/** Only the rows the snapshot is allowed to replace, with no host-qualified widening. */ -export function resolveExactDirectSshTargetWorktreeIds( - 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 snapshotPathsArePlaceable( - state: AppState, - authority: DirectSshAuthority, - worktreePaths: readonly string[] -): boolean { - const resolveWorktreeId = uniqueWorktreeIdByPath( - resolveDirectSshSnapshotWorktreeIds(state, authority) - ) - return worktreePaths.every((worktreePath) => resolveWorktreeId(worktreePath) !== null) -} - -type SnapshotPlacementCatalog = Pick< - AppState, - | 'repos' - | 'worktreesByRepo' - | 'detectedWorktreesByRepo' - | 'folderWorkspaces' - | 'projectGroups' - | 'restoredRuntimeHostIdByWorkspaceSessionKey' -> - -function captureSnapshotPlacementCatalog(state: AppState): SnapshotPlacementCatalog { - return { - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - } -} - -function snapshotPlacementCatalogChanged( - previous: SnapshotPlacementCatalog, - current: SnapshotPlacementCatalog -): boolean { - return (Object.keys(previous) as (keyof SnapshotPlacementCatalog)[]).some( - (key) => previous[key] !== current[key] - ) -} - -export async function waitForSnapshotWorktreePlacement( - store: RemoteWorkspaceSnapshotPlacementStore, - authority: DirectSshAuthority, - worktreePaths: readonly string[], - isCurrent: () => boolean, - signal?: AbortSignal, - timeoutMs: number = SNAPSHOT_WORKTREE_PLACEMENT_TIMEOUT_MS -): Promise { - if (signal?.aborted || !isCurrent()) { - return false - } - if ( - worktreePaths.length === 0 || - snapshotPathsArePlaceable(store.getState(), authority, worktreePaths) - ) { - return true - } - if (!store.subscribe) { - return false - } - const { promise, resolve } = Promise.withResolvers() - let observedCatalog = captureSnapshotPlacementCatalog(store.getState()) - let unsubscribe = (): void => {} - let timer: ReturnType | null = null - let settled = false - const finish = (placed: boolean): void => { - if (settled) { - return - } - settled = true - if (timer !== null) { - clearTimeout(timer) - } - signal?.removeEventListener('abort', onAbort) - unsubscribe() - resolve(placed) - } - const onAbort = (): void => finish(false) - timer = setTimeout(() => finish(false), timeoutMs) - signal?.addEventListener('abort', onAbort, { once: true }) - const subscribedUnsubscribe = store.subscribe((state) => { - if (!isCurrent()) { - finish(false) - return - } - const nextCatalog = captureSnapshotPlacementCatalog(state) - if (!snapshotPlacementCatalogChanged(observedCatalog, nextCatalog)) { - return - } - observedCatalog = nextCatalog - if (snapshotPathsArePlaceable(state, authority, worktreePaths)) { - finish(true) - } - }) - unsubscribe = subscribedUnsubscribe - if (settled) { - subscribedUnsubscribe() - } - if (signal?.aborted || !isCurrent()) { - finish(false) - } - if (snapshotPathsArePlaceable(store.getState(), authority, worktreePaths)) { - finish(true) - } - return promise -} diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts deleted file mode 100644 index 7d8fc937cc91..000000000000 --- a/src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * STA-3593: a fresh client adopts 0 of the host's 3 terminal tabs, then calls that answer final. - * - * The chain, all of it already load-bearing today: preparation's lineage read comes back - * `degraded` (the `DIRECT_SSH_HOST_READ_TIMEOUT_MS` deadline fired, or the listing was not - * authoritative), so `mergeExactHostLineage` never ran and `worktreesByRepo` / - * `detectedWorktreesByRepo` hold nothing for that host. A token is still issued on purpose — the - * terminals must still reconnect — so the pull proceeds, `exactTargetWorktreeIds` derives an empty - * set from the empty catalog, `uniqueWorktreeIdByPath` answers null for every host path, and the - * projection drops every host tab row. The snapshot apply then marks the target hydrated and the - * phase `'synced'`, promoting "we could not place these yet" to "the host has nothing". - * - * The oracle is the promotion, not the drop. Without a local catalog there is nowhere to put the - * rows, and that is fine and recoverable — what is not recoverable is declaring the empty result - * authoritative. An unplaceable row is `unverifiable`, never `exited` - * (docs/reference/ssh-execution-boundary.md). Getting back to a placed picture is the caller's job: - * this apply has no way back once its bounded wait expires, so it reports the paths it dropped and - * remote-workspace-target-sync.ts re-pulls when the catalog can place them. - * - * The catalog-present case is pinned alongside it so the gate cannot be satisfied by never - * hydrating anything. - */ -import { describe, expect, it, vi } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' -import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -import { resolveWorkspaceTerminalHostAuthority } from '../lib/workspace-terminal-host-authority' -import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const TARGET_ID = 'ssh-target-1' -const REPO_ROOT = '/srv/proj' -const ALPHA = `${REPO_ROOT}/alpha` -const BETA = `${REPO_ROOT}/beta` -const ALPHA_ID = `repoA::${ALPHA}` -const BETA_ID = `repoA::${BETA}` - -const authority: DirectSshAuthority = { - targetId: TARGET_ID, - providerEpoch: 'provider-epoch-1' as SshProviderEpoch, - connectionGeneration: 1 -} - -/** `degraded` is what preparation stamps when the lineage read missed its deadline. */ -function token(snapshotRevision: number): DirectSshSnapshotApplyToken { - return { - authority, - catalogRevision: 0, - repoFingerprint: 'fp', - authorityRequirement: 'required', - snapshotRevision, - outcome: 'degraded' - } -} - -function tabRow(worktreePath: string, tabId: string, sortOrder: number) { - return { - id: tabId, - worktreePath, - ptyId: `pty-${tabId}`, - title: tabId, - customTitle: null, - color: null, - sortOrder, - createdAt: sortOrder + 1 - } -} - -/** Three host terminals spread over two host workspaces. */ -function snapshot(revision: number): RemoteWorkspaceObservedSnapshot { - const tabsByWorktreePath = { - [ALPHA]: [tabRow(ALPHA, 'T1', 0), tabRow(ALPHA, 'T2', 1)], - [BETA]: [tabRow(BETA, 'T3', 0)] - } - return { - namespace: 'workspace', - revision, - updatedAt: revision, - schemaVersion: 1, - hostObservationToken: `observation-${revision}`, - session: { - activeWorktreePath: ALPHA, - activeTabId: 'T1', - tabsByWorktreePath, - terminalLayoutsByTabId: {}, - activeWorktreePathsOnShutdown: [], - activeTabIdByWorktreePath: { [ALPHA]: 'T1', [BETA]: 'T3' }, - remoteSessionIdsByTabId: { T1: 'pty-T1', T2: 'pty-T2', T3: 'pty-T3' }, - lastVisitedAtByWorktreePath: { [ALPHA]: revision, [BETA]: revision }, - defaultTerminalTabsAppliedByWorktreePath: { [ALPHA]: true, [BETA]: true } - } - } satisfies RemoteWorkspaceObservedSnapshot -} - -/** Same shape as the snapshot above, minus the terminal rows. */ -function emptySnapshot(revision: number): RemoteWorkspaceObservedSnapshot { - const base = snapshot(revision) - return { - ...base, - session: { - ...base.session, - activeTabId: null, - tabsByWorktreePath: { [ALPHA]: [], [BETA]: [] }, - activeTabIdByWorktreePath: { [ALPHA]: null, [BETA]: null }, - remoteSessionIdsByTabId: {} - } - } -} - -type TestStore = ReturnType - -function createStore(): TestStore { - const store = createTestStore() - store.setState({ - repos: [ - { - id: 'repoA', - path: REPO_ROOT, - displayName: 'Proj', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID - } as never - ], - // Only the IPC-backed reconnect is stubbed. Hydration bookkeeping and sync status stay real — - // they are the signals under test. - reconnectPersistedTerminals: (async () => {}) as never - }) - return store -} - -/** What `mergeExactHostLineage` would have written had the lineage read landed. */ -function landHostLineage(store: TestStore): void { - store.setState({ - worktreesByRepo: { - repoA: [ - makeWorktree({ - id: ALPHA_ID, - repoId: 'repoA', - path: ALPHA, - hostId: `ssh:${TARGET_ID}` - } as never), - makeWorktree({ - id: BETA_ID, - repoId: 'repoA', - path: BETA, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - }) -} - -function landAlphaLineage(store: TestStore): void { - store.setState({ - worktreesByRepo: { - repoA: [ - makeWorktree({ - id: ALPHA_ID, - repoId: 'repoA', - path: ALPHA, - hostId: `ssh:${TARGET_ID}` - } as never) - ] - } - }) -} - -function addLocalTab(store: TestStore, tabId: string): void { - const current = store.getState() - store.setState({ - tabsByWorktree: { - ...current.tabsByWorktree, - [ALPHA_ID]: [ - ...(current.tabsByWorktree[ALPHA_ID] ?? []), - { - id: tabId, - worktreeId: ALPHA_ID, - ptyId: null, - title: tabId, - customTitle: null, - color: null, - sortOrder: 99, - createdAt: Date.now() - } as never - ] - } - }) -} - -async function applySnapshot( - store: TestStore, - snap: RemoteWorkspaceObservedSnapshot -): Promise { - vi.useFakeTimers() - try { - const pending = applyDirectSshRemoteWorkspaceSnapshot({ - store, - snapshot: snap, - token: token(snap.revision), - arrival: 1, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 - }) - // Exercise the real placement deadline without spending ten wall-clock seconds per snapshot. - await vi.advanceTimersByTimeAsync(10_000) - await pending - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } -} - -function adoptedTabIds(store: TestStore): string[] { - return Object.values(store.getState().tabsByWorktree) - .flat() - .map((tab) => tab.id) - .sort() -} - -function isHydrated(store: TestStore): boolean { - return store.getState().remoteWorkspaceHydratedTargetIds.has(TARGET_ID) -} - -function syncPhase(store: TestStore): string | undefined { - return store.getState().remoteWorkspaceSyncStatusByTargetId[TARGET_ID]?.phase -} - -describe('a host snapshot whose terminal tabs cannot be placed locally', () => { - it('merges against state changed while an unplaced path waits and times out', async () => { - vi.useFakeTimers() - try { - const store = createStore() - // ALPHA is placeable, while BETA keeps the placement waiter open. - landAlphaLineage(store) - - const pending = applyDirectSshRemoteWorkspaceSnapshot({ - store, - snapshot: snapshot(100), - token: token(100), - arrival: 1, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => 0 - }) - await Promise.resolve() - await Promise.resolve() - addLocalTab(store, 'created-while-waiting') - - await vi.advanceTimersByTimeAsync(10_000) - await pending - - expect(adoptedTabIds(store)).toContain('created-while-waiting') - expect(store.getState().tabsByWorktree[ALPHA_ID]?.map((tab) => tab.id)).toEqual([ - 'T1', - 'T2', - 'created-while-waiting' - ]) - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } - }) - - it('does not call the empty result authoritative when the local catalog never landed', async () => { - const store = createStore() - // Degraded lineage: the catalog holds no worktree row for this host. - expect(store.getState().worktreesByRepo).toEqual({}) - - const snap = snapshot(1) - expect(Object.values(snap.session.tabsByWorktreePath).flat()).toHaveLength(3) - - await applySnapshot(store, snap) - - // Nowhere to put them — recoverable on its own. - expect(adoptedTabIds(store), 'no local worktree row exists to hang the host tabs on').toEqual( - [] - ) - // Not recoverable: promoting that to truth. This apply never re-pulls on its own; the deferred - // placement watch in remote-workspace-target-sync.ts is what re-pulls once the lineage lands. - expect( - isHydrated(store), - 'the host named 3 terminals and this client placed none of them, so the target is not hydrated' - ).toBe(false) - expect( - syncPhase(store), - 'a pull that dropped every host tab row has not synced the workspace' - ).not.toBe('synced') - }) - - it('revokes a hydration it granted earlier when a later snapshot cannot be placed', async () => { - const store = createStore() - landHostLineage(store) - await applySnapshot(store, snapshot(1)) - expect(isHydrated(store)).toBe(true) - - // The lineage degrades on a later reconnect: the catalog rows this client had are gone. - store.setState({ worktreesByRepo: {}, detectedWorktreesByRepo: {} }) - await applySnapshot(store, snapshot(2)) - - // Why revoke rather than merely withhold: the hydrated set is add-only, and hydration is what - // authorises uploads. A stale flag would keep uploading this incomplete picture, and an upload - // wholesale replaces the host snapshot — deleting the tabs we just failed to place. - expect( - isHydrated(store), - 'a stale hydration still authorises a replace-session upload from an incomplete picture' - ).toBe(false) - }) - - it('leaves terminal authority unverifiable, so nothing seeds or resumes over the host', async () => { - const store = createStore() - await applySnapshot(store, snapshot(1)) - - // `offline`/`error` on an un-hydrated target are the authority resolver's bounded floor and - // resolve to `none`, which authorises seeding AND sleeping-agent resume. An unplaced snapshot - // must not land in that set: the host's tabs are live, we simply could not place them. - expect( - resolveWorkspaceTerminalHostAuthority(store.getState(), ALPHA_ID), - 'an unplaced snapshot authorised seeding over live host terminals' - ).toBe('unverifiable') - }) - - it('answers for the worktrees it placed when the host names another path it cannot place', async () => { - const store = createStore() - // A second machine left a tab on beta; this client only has alpha. - landAlphaLineage(store) - await applySnapshot(store, snapshot(1)) - - expect(syncPhase(store)).toBe('conflict') - expect(isHydrated(store)).toBe(false) - expect(adoptedTabIds(store)).toEqual(['T1', 'T2']) - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), ALPHA_ID)).toBe('none') - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), BETA_ID)).toBe('unverifiable') - }) - - it('adopts every host tab and declares the target hydrated once the catalog is present', async () => { - const store = createStore() - landHostLineage(store) - - await applySnapshot(store, snapshot(1)) - - expect(adoptedTabIds(store)).toEqual(['T1', 'T2', 'T3']) - expect(store.getState().tabsByWorktree[ALPHA_ID]?.map((tab) => tab.id)).toEqual(['T1', 'T2']) - expect(store.getState().tabsByWorktree[BETA_ID]?.map((tab) => tab.id)).toEqual(['T3']) - expect(isHydrated(store)).toBe(true) - expect(syncPhase(store)).toBe('synced') - }) - - it('recovers on the next snapshot once the catalog lands', async () => { - const store = createStore() - - // First pass: the lineage read was degraded, so nothing places and the target is left - // un-hydrated on purpose. A later snapshot is how this apply, on its own, gets back. - await applySnapshot(store, snapshot(1)) - expect(adoptedTabIds(store)).toEqual([]) - expect(isHydrated(store)).toBe(false) - - // A later connect or host push arrives after the catalog landed. - landHostLineage(store) - await applySnapshot(store, snapshot(2)) - - expect( - adoptedTabIds(store), - 'an un-hydrated target never recovered the host tabs it declined to guess at' - ).toEqual(['T1', 'T2', 'T3']) - expect(isHydrated(store)).toBe(true) - expect(syncPhase(store)).toBe('synced') - }) - - it('still hydrates when the host genuinely reported no terminals', async () => { - const store = createStore() - expect(store.getState().worktreesByRepo).toEqual({}) - - // No rows were dropped here, so an empty local result is a faithful picture of the host. - await applySnapshot(store, emptySnapshot(1)) - - expect(adoptedTabIds(store)).toEqual([]) - expect(isHydrated(store)).toBe(true) - expect(syncPhase(store)).toBe('synced') - }) -}) diff --git a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts b/src/renderer/src/hooks/remote-workspace-target-sync-types.ts deleted file mode 100644 index 40d5b488c415..000000000000 --- a/src/renderer/src/hooks/remote-workspace-target-sync-types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot -} from '../../../shared/remote-workspace-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationToken -} from './direct-ssh-reconnect-coordinator' -import type { RemoteWorkspaceSnapshotPlacementStore } from './remote-workspace-snapshot-placement' - -export type RemoteWorkspaceApi = { - get: (args: { targetId: string }) => Promise - setForConnectedTargets: (args: { - session?: WorkspaceSessionState - hydratedTargetIds?: string[] - expectedRevisionsByTargetId: Record - expectedHostObservationTokensByTargetId: Record - }) => Promise<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]> -} - -export type RemoteWorkspaceTargetSyncDeps = { - store: RemoteWorkspaceSnapshotPlacementStore - 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: RemoteWorkspaceObservedSnapshot - ) => Promise - stop: () => void -} diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts deleted file mode 100644 index 65f7d2c02041..000000000000 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - RemoteWorkspaceObservedPatchResult, - RemoteWorkspaceObservedSnapshot, - RemoteWorkspaceSnapshot -} from '../../../shared/remote-workspace-types' -import type { SshProviderEpoch } from '../../../shared/ssh-types' -import { i18n } from '@/i18n/i18n' -import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization' -import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator' -import { - appState, - createHarness, - deferred, - flush, - owner, - repo, - snapshot, - token, - worktree -} from './__tests__/remote-workspace-target-sync-test-harness' - -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'], - expectedRevisionsByTargetId: { 'target-a': 0 } - }) - ) - }) - - it('acknowledges the captured blank-host edit when a newer null-root edit arrives', async () => { - const firstEdit = { targetId: 'target-a', root: null } - const newerEdit = { targetId: 'target-a', root: null } - const acknowledgeDirectSshLayoutEdits = vi.fn() - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - }, - pendingDirectSshLayoutEditsByTabId: { 'tab-a': firstEdit }, - acknowledgeDirectSshLayoutEdits - }) - const harness = createHarness(state, async () => snapshot(0)) - const uploaded = snapshot(1) - uploaded.session.terminalLayoutsByTabId = { - 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null } - } - harness.setForConnectedTargets.mockImplementationOnce(async () => { - state.pendingDirectSshLayoutEditsByTabId = { 'tab-a': newerEdit } - return [{ targetId: owner.targetId, result: { ok: true, snapshot: uploaded } }] - }) - - await harness.sync.syncAfterConnect(token()) - - const acknowledged = acknowledgeDirectSshLayoutEdits.mock.calls[0]?.[0]?.['tab-a'] - expect(acknowledged).toBe(firstEdit) - expect(state.pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(newerEdit) - }) - - 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('fails closed instead of remaining pulling after preparation keeps changing', async () => { - const state = appState() - const harness = createHarness(state, async () => null) - harness.prepareOnly.mockImplementation(async (input) => { - const preparedToken = token(input.snapshotRevision ?? null, input.catalogRevision) - harness.advanceCatalog() - return { - status: 'complete' as const, - token: preparedToken, - repoOutcomes: { - complete: 1, - 'non-authoritative': 0, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - }, - lineageOutcome: 'complete' as const - } - }) - - await harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(12)) - - expect(state.clearRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') - expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith('target-a', { - phase: 'conflict', - direction: 'pull', - revision: 12, - updatedAt: 12, - hostObservationToken: 'observation-12' - }) - expect(state.hydrateTabsSession).not.toHaveBeenCalled() - }) - - it('fails closed when current snapshot preparation cannot start', async () => { - const state = appState() - const harness = createHarness(state, async () => null) - harness.capturePreparationInput.mockResolvedValueOnce(null) - - await harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(13)) - - expect(state.clearRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') - expect(state.setRemoteWorkspaceSyncStatus).toHaveBeenLastCalledWith('target-a', { - phase: 'conflict', - direction: 'pull', - revision: 13, - updatedAt: 13, - hostObservationToken: 'observation-13' - }) - expect(state.hydrateTabsSession).not.toHaveBeenCalled() - }) - - 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('adopts host tabs when their worktree catalog row lands after the snapshot', async () => { - const hydrateTabsSession = vi.fn() - const markRemoteWorkspaceHydrated = vi.fn() - const clearRemoteWorkspaceHydrated = vi.fn() - let catalogProjectionReads = 0 - const emptyWorktreesByRepo = {} - Object.defineProperty(emptyWorktreesByRepo, 'repo-a', { - enumerable: true, - get: () => { - catalogProjectionReads += 1 - return [] - } - }) - const state = appState({ - worktreesByRepo: emptyWorktreesByRepo, - hydrateTabsSession, - markRemoteWorkspaceHydrated, - clearRemoteWorkspaceHydrated - }) - const harness = createHarness(state, async () => null) - const incoming = snapshot(12, { - '/remote/work': [ - { - id: 'host-tab', - worktreePath: '/remote/work', - ptyId: 'ssh:target-a@@pty-1' - } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] - ] - }) - - const pending = harness.sync.applyUnsolicitedSnapshot('target-a', incoming) - await flush() - const readsBeforeUnrelatedWrites = catalogProjectionReads - for (let write = 0; write < 100; write += 1) { - harness.publishState() - } - expect(catalogProjectionReads).toBe(readsBeforeUnrelatedWrites) - state.worktreesByRepo = appState().worktreesByRepo - harness.advanceCatalog() - harness.publishState() - await pending - - expect( - hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'].map( - (tab: { id: string }) => tab.id - ) - ).toEqual(['host-tab']) - expect(markRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') - expect(clearRemoteWorkspaceHydrated).toHaveBeenCalledOnce() - expect(clearRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') - }) - - it('re-pulls a conflicted target once the catalog can place the host tabs it dropped', async () => { - vi.useFakeTimers() - const hydrateTabsSession = vi.fn() - const markRemoteWorkspaceHydrated = vi.fn() - const state = appState({ - worktreesByRepo: {}, - hydrateTabsSession, - markRemoteWorkspaceHydrated - }) - const incoming = snapshot(12, { - '/remote/work': [ - { - id: 'host-tab', - worktreePath: '/remote/work', - ptyId: 'ssh:target-a@@pty-1' - } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] - ] - }) - const get = vi.fn(async () => incoming) - const harness = createHarness(state, get) - try { - const pending = harness.sync.applyUnsolicitedSnapshot('target-a', incoming) - await flush() - // The bounded in-apply wait expires with the host's path still unplaceable. - await vi.advanceTimersByTimeAsync(10_000) - await pending - expect( - markRemoteWorkspaceHydrated, - 'adopting none of the host tabs is not the host picture' - ).not.toHaveBeenCalled() - expect( - get, - 'nothing should re-pull while the path is still unplaceable' - ).not.toHaveBeenCalled() - - // Minutes later the user opens the worktree and its catalog row finally lands. - state.worktreesByRepo = appState().worktreesByRepo - harness.publishState() - await vi.advanceTimersByTimeAsync(0) - await flush() - await vi.advanceTimersByTimeAsync(0) - - expect(get).toHaveBeenCalledWith({ targetId: 'target-a' }) - expect( - hydrateTabsSession.mock.calls - .at(-1)?.[0] - .tabsByWorktree['repo-a::/remote/work'].map((tab: { id: string }) => tab.id), - 'the host tab dropped by the cold-catalog pull was never hydrated' - ).toEqual(['host-tab']) - // Hydration is what lifts the upload suppression, so the host ledger stops going stale too. - expect(markRemoteWorkspaceHydrated).toHaveBeenCalledWith('target-a') - } finally { - harness.sync.stop() - vi.useRealTimers() - } - }) - - it('keeps only the latest placement waiter and fences a burst to the newest snapshot', async () => { - vi.useFakeTimers() - const hydrateTabsSession = vi.fn() - const state = appState({ worktreesByRepo: {}, hydrateTabsSession }) - const harness = createHarness(state, async () => null) - const pending: Promise[] = [] - try { - for (let revision = 20; revision < 52; revision += 1) { - pending.push( - harness.sync.applyUnsolicitedSnapshot( - 'target-a', - snapshot(revision, { - '/remote/work': [ - { - id: `host-tab-${revision}`, - worktreePath: '/remote/work', - ptyId: `ssh:target-a@@pty-${revision}` - } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] - ] - }) - ) - ) - await flush() - expect(harness.activeStateListenerCount()).toBe(1) - expect(vi.getTimerCount()).toBe(1) - } - - expect(harness.peakStateListenerCount()).toBe(1) - state.worktreesByRepo = appState().worktreesByRepo - harness.publishState() - await Promise.all(pending) - - expect(harness.activeStateListenerCount()).toBe(0) - expect(hydrateTabsSession).toHaveBeenCalledOnce() - expect( - hydrateTabsSession.mock.calls[0][0].tabsByWorktree['repo-a::/remote/work'].map( - (tab: { id: string }) => tab.id - ) - ).toEqual(['host-tab-51']) - await vi.runAllTimersAsync() - expect(vi.getTimerCount()).toBe(0) - } finally { - harness.sync.stop() - vi.useRealTimers() - } - }) - - it('keeps only the latest readiness poll timer during a snapshot burst', async () => { - vi.useFakeTimers() - const state = appState({ workspaceSessionReady: false }) - const harness = createHarness(state, async () => null) - const pending: Promise[] = [] - try { - for (let revision = 53; revision < 85; revision += 1) { - pending.push(harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(revision))) - await flush() - expect(vi.getTimerCount()).toBe(1) - } - - harness.sync.stop() - await Promise.all(pending) - - expect(vi.getTimerCount()).toBe(0) - expect(state.hydrateTabsSession).not.toHaveBeenCalled() - } finally { - harness.sync.stop() - vi.useRealTimers() - } - }) - - it('does not publish a revision-zero push after a newer snapshot arrives', async () => { - const state = appState({ - tabsByWorktree: { - 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] - } - }) - const pendingPush = - deferred<{ targetId: string; result: RemoteWorkspaceObservedPatchResult }[]>() - const pendingCapture = deferred() - const harness = createHarness(state, async () => snapshot(0)) - harness.setForConnectedTargets.mockImplementationOnce(() => pendingPush.promise) - - const first = harness.sync.syncAfterConnect(token()) - await flush() - expect(harness.setForConnectedTargets).toHaveBeenCalledOnce() - - harness.capturePreparationInput.mockImplementationOnce(() => pendingCapture.promise) - const second = harness.sync.applyUnsolicitedSnapshot('target-a', snapshot(85)) - await flush() - pendingPush.resolve([{ targetId: 'target-a', result: { ok: true, snapshot: snapshot(1) } }]) - await first - - expect(state.setRemoteWorkspaceSyncStatus).not.toHaveBeenCalledWith( - 'target-a', - expect.objectContaining({ direction: 'push' }) - ) - - harness.sync.stop() - pendingCapture.resolve({ - ...owner, - catalogRevision: 1, - repoRefs: [{ repoId: 'repo-a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'workspace-snapshot', - snapshotRevision: 85 - }) - await second - }) - - it('stopping snapshot sync cancels the active placement waiter immediately', async () => { - vi.useFakeTimers() - const state = appState({ worktreesByRepo: {} }) - const harness = createHarness(state, async () => null) - const pending = harness.sync.applyUnsolicitedSnapshot( - 'target-a', - snapshot(52, { - '/remote/work': [ - { - id: 'host-tab', - worktreePath: '/remote/work', - ptyId: 'ssh:target-a@@pty-52' - } as RemoteWorkspaceSnapshot['session']['tabsByWorktreePath'][string][number] - ] - }) - ) - try { - await flush() - expect(harness.activeStateListenerCount()).toBe(1) - expect(vi.getTimerCount()).toBe(1) - - harness.sync.stop() - await pending - - expect(harness.activeStateListenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - expect(state.hydrateTabsSession).not.toHaveBeenCalled() - } finally { - harness.sync.stop() - 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] - ] - }) - - vi.useFakeTimers() - try { - const pending = harness.sync.applyUnsolicitedSnapshot('target-a', incoming) - await vi.advanceTimersByTimeAsync(10_000) - await pending - } finally { - harness.sync.stop() - vi.useRealTimers() - } - - 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 deleted file mode 100644 index 05d027ece2e7..000000000000 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { translate } from '@/i18n/i18n' -import { buildWorkspaceSessionPayload } from '../lib/workspace-session' -import type { - DirectSshPreparationToken, - DirectSshSnapshotApplyToken -} from './direct-ssh-reconnect-coordinator' -import { buildDirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator' -import { resolveExactDirectSshTargetWorktreeIds } from './remote-workspace-snapshot-placement' -import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -import { createRemoteWorkspaceSnapshotArrivalCoordinator } from './remote-workspace-snapshot-arrival-coordinator' -import { createDeferredSnapshotPlacementRetries } from './remote-workspace-deferred-placement-retry' -import { applyRemoteWorkspacePushStatus } from './remote-workspace-push-status' -import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' -import { waitForRemoteWorkspaceSessionReady } from './remote-workspace-session-readiness' -import type { - RemoteWorkspaceTargetSync, - RemoteWorkspaceTargetSyncDeps -} from './remote-workspace-target-sync-types' - -export type { - RemoteWorkspaceTargetSync, - RemoteWorkspaceTargetSyncDeps -} from './remote-workspace-target-sync-types' - -const MAX_SNAPSHOT_APPLY_ATTEMPTS = 3 - -export function createRemoteWorkspaceTargetSync( - deps: RemoteWorkspaceTargetSyncDeps -): RemoteWorkspaceTargetSync { - const arrivals = createRemoteWorkspaceSnapshotArrivalCoordinator() - const deferredPlacementRetries = createDeferredSnapshotPlacementRetries({ - store: deps.store, - getCurrentAuthority: deps.getCurrentAuthority, - getSnapshot: (targetId) => deps.remoteWorkspace.get({ targetId }), - applySnapshot: (targetId, snapshot) => applyUnsolicitedSnapshot(targetId, snapshot) - }) - - const isArrivalCurrent = arrivals.isCurrent - - const markSnapshotConflict = ( - authority: DirectSshAuthority, - snapshot: RemoteWorkspaceObservedSnapshot, - arrival: number - ): void => { - if (!isArrivalCurrent(authority.targetId, arrival)) { - return - } - const state = deps.store.getState() - state.clearRemoteWorkspaceHydrated(authority.targetId) - state.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'conflict', - direction: 'pull', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken - }) - } - - const applySnapshotWithCurrentPreparation = async ( - authority: DirectSshAuthority, - snapshot: RemoteWorkspaceObservedSnapshot, - arrival: number, - arrivalSignal: AbortSignal, - initialToken: DirectSshSnapshotApplyToken - ): Promise => { - let applyToken = initialToken - for (let attempt = 0; attempt < MAX_SNAPSHOT_APPLY_ATTEMPTS; attempt += 1) { - let unplacedTabWorktreePaths: readonly string[] = [] - const result = await applyDirectSshRemoteWorkspaceSnapshot({ - store: deps.store, - snapshot, - token: applyToken, - arrival, - arrivalSignal, - isArrivalCurrent, - isPreparationTokenCurrent: deps.isPreparationTokenCurrent, - waitForWorkspaceSessionReady: (signal) => - waitForRemoteWorkspaceSessionReady(deps.store, signal), - finalizeHydratedTerminals: deps.finalizeHydratedTerminals, - onUnplacedTabWorktreePaths: (worktreePaths) => { - unplacedTabWorktreePaths = worktreePaths - } - }) - if (result === 'applied') { - deferredPlacementRetries.watch(authority, unplacedTabWorktreePaths) - } - if (result !== 'stale' || !isArrivalCurrent(authority.targetId, arrival)) { - return - } - if (attempt === MAX_SNAPSHOT_APPLY_ATTEMPTS - 1) { - break - } - const input = await deps.capturePreparationInput( - authority, - 'workspace-snapshot', - snapshot.revision - ) - if (!input || !isArrivalCurrent(authority.targetId, arrival)) { - markSnapshotConflict(authority, snapshot, arrival) - return - } - const prepared = await deps.prepareOnly(input) - if ( - !prepared.token || - !deps.isPreparationTokenCurrent(prepared.token) || - !isArrivalCurrent(authority.targetId, arrival) - ) { - markSnapshotConflict(authority, snapshot, arrival) - return - } - const refreshedToken = buildDirectSshSnapshotApplyToken(prepared.token, snapshot.revision) - if (!refreshedToken) { - markSnapshotConflict(authority, snapshot, arrival) - return - } - applyToken = refreshedToken - } - markSnapshotConflict(authority, snapshot, arrival) - } - - const syncAfterConnectArrival = async ( - token: DirectSshPreparationToken, - arrival: number, - arrivalSignal: AbortSignal - ): Promise => { - const { authority } = token - const workspaceReady = await waitForRemoteWorkspaceSessionReady(deps.store, arrivalSignal) - 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 = resolveExactDirectSshTargetWorktreeIds(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 applySnapshotWithCurrentPreparation( - authority, - snapshot, - arrival, - arrivalSignal, - applyToken - ) - } - return - } - deps.store.getState().markRemoteWorkspaceHydrated(authority.targetId) - if (!hasLocalTabs) { - deps.store.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'idle', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken, - message: translate('auto.hooks.useIpcEvents.2ec42e1c52', 'No remote workspace yet') - }) - return - } - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return - } - const pendingLayoutEdits = deps.store.getState().pendingDirectSshLayoutEditsByTabId - const results = await deps.remoteWorkspace.setForConnectedTargets({ - session: buildWorkspaceSessionPayload(deps.store.getState()), - hydratedTargetIds: [authority.targetId], - expectedRevisionsByTargetId: { [authority.targetId]: snapshot.revision }, - expectedHostObservationTokensByTargetId: { - [authority.targetId]: snapshot.hostObservationToken - } - }) - if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { - return - } - const result = results.find((entry) => entry.targetId === authority.targetId)?.result - if (result?.ok) { - const currentState = deps.store.getState() - currentState.acknowledgeDirectSshLayoutEdits( - Object.fromEntries( - Object.entries(pendingLayoutEdits ?? {}).flatMap(([tabId, entry]) => { - const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] - return entry.targetId === authority.targetId && - uploaded && - terminalLayoutNodeEqual(entry.root, uploaded.root) - ? [[tabId, entry]] - : [] - }) - ) - ) - } - applyRemoteWorkspacePushStatus(deps.store.getState(), authority.targetId, result, snapshot) - } - - const syncAfterConnect = (token: DirectSshPreparationToken): Promise => - arrivals.run(token.authority.targetId, (arrival, signal) => - syncAfterConnectArrival(token, arrival, signal) - ) - - const applyUnsolicitedSnapshotArrival = async ( - targetId: string, - snapshot: RemoteWorkspaceObservedSnapshot, - arrival: number, - arrivalSignal: AbortSignal - ): Promise => { - const authority = deps.getCurrentAuthority(targetId) - if (!authority) { - return - } - const state = deps.store.getState() - state.clearRemoteWorkspaceHydrated(authority.targetId) - state.setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'pulling', - direction: 'pull', - revision: snapshot.revision, - updatedAt: snapshot.updatedAt, - hostObservationToken: snapshot.hostObservationToken - }) - const input = await deps.capturePreparationInput( - authority, - 'workspace-snapshot', - snapshot.revision - ) - if (!input) { - markSnapshotConflict(authority, snapshot, arrival) - return - } - if (!isArrivalCurrent(targetId, arrival)) { - return - } - const prepared = await deps.prepareOnly(input) - if (!prepared.token) { - markSnapshotConflict(authority, snapshot, arrival) - return - } - if (!isArrivalCurrent(targetId, arrival)) { - return - } - const applyToken = buildDirectSshSnapshotApplyToken(prepared.token, snapshot.revision) - if (!applyToken) { - markSnapshotConflict(authority, snapshot, arrival) - return - } - await applySnapshotWithCurrentPreparation( - authority, - snapshot, - arrival, - arrivalSignal, - applyToken - ) - } - - const applyUnsolicitedSnapshot = ( - targetId: string, - snapshot: RemoteWorkspaceObservedSnapshot - ): Promise => - arrivals.run(targetId, (arrival, signal) => - applyUnsolicitedSnapshotArrival(targetId, snapshot, arrival, signal) - ) - - return { - syncAfterConnect, - applyUnsolicitedSnapshot, - stop: () => { - deferredPlacementRetries.stop() - arrivals.stop() - } - } -} diff --git a/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts b/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts index 2abdfaedbd35..e8c3d46077bd 100644 --- a/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts +++ b/src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts @@ -60,9 +60,8 @@ describe('shouldRetryPaneSpawnOnSshReconnect', () => { }) it('leaves a hydrated tab alone whose ptyId was nulled but whose layout still names its PTY', () => { - // clearTransientTerminalState nulls tab.ptyId on every hydrated row unconditionally, and - // finalizeHydratedTerminals runs this gate straight afterwards — while hydrating the host's - // own snapshot of the sessions it is still running. + // clearTransientTerminalState nulls tab.ptyId on every hydrated row unconditionally, and a + // reconnect retry pass runs this gate straight afterwards. expect( shouldRetryPaneSpawnOnSshReconnect({ targetId: 'conn-1', diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts index d8bfbd54d4c4..efba69b63376 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts @@ -65,103 +65,6 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).not.toHaveBeenCalled() }) - it('waits for the remote workspace client id before dropping self notifications', async () => { - const hydrateWorkspaceSession = vi.fn() - const hydrateTabsSession = vi.fn() - const hydrateEditorSession = vi.fn() - const hydrateBrowserSession = vi.fn() - let resolveClientId!: (id: string) => void - const clientId = new Promise((resolve) => { - resolveClientId = resolve - }) - const onChangedListenerRef: { - current: - | ((event: { - targetId: string - sourceClientId?: string - snapshot: Record - }) => void) - | null - } = { current: null } - const storeState: StoreLike = buildStoreState({ - workspaceSessionReady: true, - repos: [{ id: 'repo-1', connectionId: 'conn-1' }], - worktreesByRepo: { 'repo-1': [{ id: 'repo-1::/repo', repoId: 'repo-1' }] }, - hydrateWorkspaceSession, - hydrateTabsSession, - hydrateEditorSession, - hydrateBrowserSession, - markRemoteWorkspaceHydrated: vi.fn(), - setRemoteWorkspaceSyncStatus: vi.fn(), - reconnectPersistedTerminals: vi.fn(() => Promise.resolve()) - }) - - stubReactSyncEffect() - vi.doMock('../store', () => ({ - useAppStore: { - subscribe: vi.fn(() => () => {}), - getState: () => storeState - } - })) - stubAuxiliaryModules() - vi.stubGlobal( - 'window', - buildWindowApi({ - onSet: () => () => {}, - remoteWorkspace: { - clientId: () => clientId, - onChanged: (cb: typeof onChangedListenerRef.current) => { - onChangedListenerRef.current = cb - return () => {} - } - } - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - - useIpcEvents() - await Promise.resolve() - - onChangedListenerRef.current?.({ - targetId: 'conn-1', - sourceClientId: 'client-self', - snapshot: { - revision: 1, - updatedAt: Date.now(), - session: { - activeWorktreePath: '/repo', - activeTabId: 'tab-1', - tabsByWorktreePath: { - '/repo': [ - { - id: 'tab-1', - ptyId: null, - worktreePath: '/repo', - title: 'Remote', - customTitle: null, - color: null, - sortOrder: 1, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: {} - } - } - }) - await Promise.resolve() - expect(hydrateWorkspaceSession).not.toHaveBeenCalled() - - resolveClientId('client-self') - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(hydrateWorkspaceSession).not.toHaveBeenCalled() - expect(hydrateTabsSession).not.toHaveBeenCalled() - expect(hydrateEditorSession).not.toHaveBeenCalled() - expect(hydrateBrowserSession).not.toHaveBeenCalled() - }) - it('silently discards snapshot entries whose tabs are still unknown', async () => { const setAgentStatus = vi.fn() const getSnapshot = vi.fn(() => diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts index fc019448ac7d..ecc7882b0e92 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts @@ -208,7 +208,6 @@ describe('useIpcEvents agent status snapshot integration', () => { }, setSshTargetsMetadata: vi.fn(), setRemovedSshTargetLabels: vi.fn(), - setRemoteWorkspaceSyncStatus: vi.fn(), clearRemoteDetectedAgents: vi.fn(), clearDirectSshTargetPtyBindings: vi.fn(), clearRemovedSshTargetState: vi.fn() @@ -226,7 +225,6 @@ describe('useIpcEvents agent status snapshot integration', () => { return { token: null } }), correctUnboundTerminals: vi.fn(() => 0), - finalizeHydratedTerminals: vi.fn(() => 0), invalidate: vi.fn(), stop: vi.fn() } @@ -427,7 +425,6 @@ describe('useIpcEvents agent status snapshot integration', () => { setDetectedPorts, setSshTargetsMetadata: vi.fn(), setRemovedSshTargetLabels: vi.fn(), - setRemoteWorkspaceSyncStatus: vi.fn(), fetchRuntimeEnvironmentRepos: vi.fn(async () => []), fetchWorktreeLineage: vi.fn(async () => undefined), clearRemoteDetectedAgents: vi.fn(), @@ -441,7 +438,6 @@ describe('useIpcEvents agent status snapshot integration', () => { replaceAuthority: vi.fn(), prepareOnly: vi.fn(async () => ({ token: null })), correctUnboundTerminals: vi.fn(() => 0), - finalizeHydratedTerminals: vi.fn(() => 0), invalidate: vi.fn(), stop: vi.fn() } diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 9c204026b7aa..985b90c12e8b 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -23,7 +23,6 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'orcaProfiles.onAuthStatusChanged', 'pty.onExit', 'rateLimits.onUpdate', - 'remoteWorkspace.onChanged', 'repos.onChanged', 'runtime.onBrowserDriverChanged', 'runtime.onBrowserRemoteViewersChanged', @@ -197,7 +196,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ssh.onDetectedPortsChanged', 'ssh.onStateChanged', 'ui.onSystemResumed', - 'remoteWorkspace.onChanged', 'ui.onTerminalZoom', 'agentStatus.onSet', 'agentStatus.onClear', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bedcea74181f..f0fe91d8ea84 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1214,12 +1214,6 @@ "f6300deb8b": "New Browser Tab", "7a64b31991": "Local terminal creation is unavailable while a remote runtime is active", "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", "ef223fbb6b": "A device tried to connect but is not paired", "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", "6573cfe955": "Open Mobile Settings", @@ -3926,9 +3920,7 @@ "runtime_workspace_window_closed": "Workspace window closed", "connectedHostCount_one": "{{count}} host", "connectedHostCount_other": "{{count}} hosts", - "connecting": "Connecting…", - "workspaceConflict": "Workspace conflict", - "workspaceSyncError": "Workspace sync error" + "connecting": "Connecting…" }, "StatusBar": { "9659e38343": "Ports", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 696a1d5f56d9..d9cd98234ca7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1042,12 +1042,6 @@ "f6300deb8b": "Nueva pestaña del navegador", "7a64b31991": "La creación de terminales locales no está disponible mientras haya un host remoto activo", "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", "ef223fbb6b": "A device tried to connect but is not paired", "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", "6573cfe955": "Open Mobile Settings", @@ -3704,8 +3698,6 @@ "connectedHostCount_one": "{{count}} host", "connectedHostCount_other": "{{count}} hosts", "connecting": "Conectando…", - "workspaceConflict": "Conflicto del espacio de trabajo", - "workspaceSyncError": "Error de sincronización del espacio de trabajo", "runtime_unavailable_transport_up": "Orca no disponible", "runtime_workspace_window_closed": "Ventana del espacio de trabajo cerrada" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 84a49128b024..afff74dfe32c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1143,12 +1143,6 @@ "f6300deb8b": "Nouvel onglet de navigateur", "7a64b31991": "La création d'un terminal local est indisponible tant qu'un runtime distant est actif", "60428567b4": "L'affichage d'un terminal local est indisponible tant qu'un runtime distant est actif", - "f8aaf2bde3": "Espace de travail téléversé", - "2fe88c2e06": "Synchronisation de l'espace de travail distant indisponible", - "workspaceChangedOnAnotherDevice": "Espace de travail modifié sur un autre appareil", - "2ec42e1c52": "Pas encore de espace de travail distant", - "88214a785b": "La synchronisation de l'espace de travail a attendu l'hydratation de la session locale et a expiré", - "4f78ba5885": "Espace de travail synchronisé", "ef223fbb6b": "Un appareil a tenté de se connecter mais n'est pas jumelé", "11992d0337": "S'il s'agissait de votre téléphone ou d'un autre client Orca, réappairez-le depuis Paramètres → Mobile.", "6573cfe955": "Ouvrir les paramètres mobiles", @@ -3810,8 +3804,6 @@ "connectedHostCount_one": "{{count}} hôte", "connectedHostCount_other": "{{count}} hôtes", "connecting": "Connexion…", - "workspaceConflict": "Conflit de espace de travail", - "workspaceSyncError": "Erreur de synchronisation de l'espace de travail", "runtime_unavailable_transport_up": "Orque indisponible" }, "StatusBar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0b6b46a75879..e0de30ef0266 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1042,12 +1042,6 @@ "f6300deb8b": "新規ブラウザタブ", "7a64b31991": "リモートランタイムがアクティブな間は、ローカルターミナルの作成は利用できません", "60428567b4": "リモートランタイムがアクティブな間は、ローカルターミナルの表示は利用できません", - "f8aaf2bde3": "ワークスペースがアップロードされました", - "2fe88c2e06": "リモートワークスペースの同期は利用できません", - "workspaceChangedOnAnotherDevice": "ワークスペースが別のデバイスで変更されました", - "2ec42e1c52": "リモートワークスペースはまだありません", - "88214a785b": "ワークスペースの同期はローカルセッションのハイドレーションを待機し、タイムアウトになりました", - "4f78ba5885": "ワークスペースが同期されました", "ef223fbb6b": "接続を試みたデバイスがペアリングされていません", "11992d0337": "お使いのスマートフォンや別の Orca クライアントの場合は、[設定] → [モバイル] から再度ペアリングしてください。", "6573cfe955": "モバイル設定を開く", @@ -3704,8 +3698,6 @@ "connectedHostCount_one": "{{count}} 台のホスト", "connectedHostCount_other": "{{count}} 台のホスト", "connecting": "接続中…", - "workspaceConflict": "ワークスペースの競合", - "workspaceSyncError": "ワークスペースの同期エラー", "runtime_unavailable_transport_up": "Orca は利用できません", "runtime_workspace_window_closed": "ワークスペースウィンドウを閉じた" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 89421c9541ec..10c6e6d456a4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1042,12 +1042,6 @@ "f6300deb8b": "새 브라우저 탭", "7a64b31991": "원격 런타임이 활성화된 동안에는 로컬 terminal 생성을 사용할 수 없습니다.", "60428567b4": "원격 런타임이 활성화된 동안에는 로컬 terminal 표시를 사용할 수 없습니다.", - "f8aaf2bde3": "워크스페이스가 업로드됨", - "2fe88c2e06": "원격 워크스페이스 동기화를 사용할 수 없습니다.", - "workspaceChangedOnAnotherDevice": "다른 기기에서 워크스페이스가 변경되었습니다.", - "2ec42e1c52": "아직 원격 워크스페이스가 없습니다.", - "88214a785b": "워크스페이스 동기화가 로컬 세션 하이드레이션을 기다렸다가 시간 초과되었습니다.", - "4f78ba5885": "워크스페이스가 동기화되었습니다.", "ef223fbb6b": "A device tried to connect but is not paired", "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", "6573cfe955": "Open Mobile Settings", @@ -3704,8 +3698,6 @@ "connectedHostCount_one": "호스트 {{count}}개", "connectedHostCount_other": "호스트 {{count}}개", "connecting": "연결 중…", - "workspaceConflict": "워크스페이스 충돌", - "workspaceSyncError": "워크스페이스 동기화 오류", "runtime_unavailable_transport_up": "Orca를 사용할 수 없습니다.", "runtime_workspace_window_closed": "워크스페이스 창이 닫혔습니다." }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 27f765e9869f..11528053b9ae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1042,12 +1042,6 @@ "f6300deb8b": "新浏览器选项卡", "7a64b31991": "当远程运行时处于活动状态时,本地终端创建不可用", "60428567b4": "当远程运行时处于活动状态时,本地终端显示不可用", - "f8aaf2bde3": "工作区已上传", - "2fe88c2e06": "远程工作区同步不可用", - "workspaceChangedOnAnotherDevice": "工作区已在另一台设备上更改", - "2ec42e1c52": "还没有远程工作区", - "88214a785b": "工作区同步等待本地会话恢复(hydration)并超时", - "4f78ba5885": "工作区已同步", "ef223fbb6b": "有设备尝试连接但未配对", "11992d0337": "如果这是您的手机或其他 Orca 客户端,请从设置 → 移动端重新配对。", "6573cfe955": "打开移动端设置", @@ -3704,8 +3698,6 @@ "connectedHostCount_one": "{{count}} 个主机", "connectedHostCount_other": "{{count}} 个主机", "connecting": "正在连接…", - "workspaceConflict": "工作区冲突", - "workspaceSyncError": "工作区同步错误", "runtime_unavailable_transport_up": "Orca 不可用", "runtime_workspace_window_closed": "工作区窗口已关闭" }, diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts index 7919124e93c2..81577695acc1 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts @@ -1,14 +1,12 @@ /** - * STA-3500: a direct-SSH workspace's tab rows only reach the renderer after connect, via - * remoteWorkspace.get → merge — a network round trip. The startup resume sweep fires before that - * lands, sees no pane owning a sleeping record, and cold-resumes a session the host is still - * running. STA-3498 counted five concurrent `claude` processes on one transcript that way. + * STA-3500: the startup resume sweep must not cold-resume a direct-SSH session before its target + * connects. STA-3498 counted five concurrent `claude` processes on one transcript that way. * * The paired-runtime flavor already parks per pane (host-mirrored-pane-liveness.ts), but that guard * only recognises `web-terminal-*` surface ids, so direct SSH fell straight through it. * - * Both halves matter: the sweep must decline while the host is unanswered, and it must still wake a - * genuinely sleeping agent once the host answers. + * Both halves matter: the sweep must decline while the target is unreachable, and it must still wake + * a genuinely sleeping agent once the target connects. */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' @@ -93,7 +91,7 @@ function resumedTabCount(worktreeId: string): number { } describe('sleeping-agent resume across the direct-SSH hydration gap', () => { - it('does not resume a remote session before the host has answered', () => { + it('does not resume a remote session before its SSH target connects', () => { const record = seedColdDirectSshStart() const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) @@ -104,34 +102,24 @@ describe('sleeping-agent resume across the direct-SSH hydration gap', () => { expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) }) - it('wakes the same session once the host answers and holds no pane for it', () => { + it('wakes the same session once its SSH target connects', () => { const record = seedColdDirectSshStart() resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) - useAppStore.getState().markRemoteWorkspaceHydrated(TARGET_ID) + useAppStore.getState().setSshConnectionState(TARGET_ID, { + targetId: TARGET_ID, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) - expect(launched, 'the deferred agent never woke after the host answered').toBe(1) + expect(launched, 'the deferred agent never woke after the target connected').toBe(1) const state = useAppStore.getState() expect(state.tabsByWorktree[WORKTREE_ID]?.[0]?.launchAgent).toBe('claude') expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) - it.each([ - ['wakes it when the host named tabs only on another path', '/srv/proj/another-machines', 1], - ['keeps waiting when the host named tabs here it could not place', PATH, 0] - ])('after a snapshot that could not place every tab, %s', (_label, unplacedPath, expected) => { - seedColdDirectSshStart() - useAppStore.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { - phase: 'conflict', - direction: 'pull', - unplacedTabWorktreePaths: [unplacedPath] - }) - - expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(expected) - expect(resumedTabCount(WORKTREE_ID)).toBe(expected) - }) - it('leaves a purely local workspace resuming with no added latency', () => { seedColdDirectSshStart() const localRecord = { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts index c25dab229647..a9d0846c3afc 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-execution-host-scope.test.ts @@ -158,9 +158,9 @@ describe('agentResumeOriginNamesAnotherExecutionHost', () => { ) }) -/** The SSH workspace after its host has answered, so terminal-host authority is decided and the - * sweep is allowed to act. Without the hydration mark the sweep declines for an unrelated reason - * and every assertion below would pass vacuously. */ +/** The SSH workspace with its target connected, so terminal-host authority is decided and the + * sweep is allowed to act. Without the connection the sweep declines for an unrelated reason and + * every assertion below would pass vacuously. */ function seedAnsweredSshWorkspace(...records: SleepingAgentSessionRecord[]): void { useAppStore.setState({ repos: [{ ...TEST_REPO, id: 'repo-1', path: '/home/neil/projects', connectionId: TARGET_ID }], @@ -179,7 +179,12 @@ function seedAnsweredSshWorkspace(...records: SleepingAgentSessionRecord[]): voi records.map((record) => [record.paneKey, record]) ) }) - useAppStore.getState().markRemoteWorkspaceHydrated(TARGET_ID) + useAppStore.getState().setSshConnectionState(TARGET_ID, { + targetId: TARGET_ID, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) } describe('the resume sweep under execution-host scope', () => { diff --git a/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts b/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts deleted file mode 100644 index e35e46fc908c..000000000000 --- a/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The resume half of the terminal-state floor. - * - * `workspace-terminal-host-authority.ts` says an SSH target whose sync terminated in - * `offline`/`error` without ever hydrating answers `none`, so this client may act. The seeding - * consumer is covered end to end (worktree-agent-activation-seam.test.ts); the sleeping-agent - * consumer (resume-sleeping-agent-session.ts) was only covered at the predicate. Without this, - * a failed target's agents stay unresumable for the rest of the app session and nothing fails. - */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { TerminalTab } from '../../../shared/terminal-tab-types' -import { useAppStore } from '@/store' -import { makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' -import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority' -import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) - -const initialAppStoreState = useAppStore.getState() -const TARGET_ID = 'ssh-target-1' -const WORKTREE_ID = 'repoSsh::/srv/proj/feature' - -afterEach(() => { - useAppStore.setState(initialAppStoreState, true) -}) - -function seedFailedSshTarget(phase?: 'offline' | 'error' | 'pulling'): void { - const tab: TerminalTab = { - id: 'tab-1', - ptyId: null, - worktreeId: WORKTREE_ID, - title: 'shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - const record: SleepingAgentSessionRecord = { - paneKey: 'tab-1:leaf-1', - tabId: 'tab-1', - worktreeId: WORKTREE_ID, - agent: 'pi', - providerSession: { key: 'session_id', id: 'pi-session-1', transcriptPath: '/tmp/pi-1.jsonl' }, - prompt: '', - state: 'working', - capturedAt: 1, - updatedAt: 1, - origin: 'worktree-sleep' - } - useAppStore.setState({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape. - repos: [ - { - id: 'repoSsh', - path: '/srv/proj', - displayName: 'repoSsh', - badgeColor: '#000', - addedAt: 0, - connectionId: TARGET_ID - } - ] as never, - worktreesByRepo: { - repoSsh: [ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape. - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repoSsh', - path: '/srv/proj/feature', - hostId: `ssh:${TARGET_ID}` - } as never) - ] - }, - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: - phase === undefined ? {} : { [TARGET_ID]: { phase, direction: 'pull' as const } }, - tabsByWorktree: { [WORKTREE_ID]: [tab] }, - sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } - }) -} - -describe('sleeping-agent resume on a failed SSH target', () => { - it.each(['offline', 'error'] as const)( - 'resumes a sleeping agent once a sync terminates in %s without ever hydrating', - (phase) => { - seedFailedSshTarget(phase) - - expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe( - 'none' - ) - // The gate this exists for: a target that failed must not stay unresumable for the session. - expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(1) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined() - } - ) - - it('still declines to resume while the host has not answered', () => { - // Control: an in-flight sync is `unverifiable`, and resuming there forks a session the host - // may still be running. The floor must not widen into "resume whenever we are unsure". - seedFailedSshTarget('pulling') - - expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe( - 'unverifiable' - ) - expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeDefined() - }) - - it('still declines to resume when no sync status exists at all', () => { - seedFailedSshTarget(undefined) - - expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe( - 'unverifiable' - ) - expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) - }) -}) diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 1c6f23c7cb35..72ccbbdfa5fe 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -31,11 +31,7 @@ import { * * Field classification lives in FIELD_OWNERSHIP below and is checked for * exhaustiveness at compile time, mirroring SESSION_RELEVANT_FIELDS in - * workspace-session.ts. The remote-workspace SSH projection - * (src/shared/remote-workspace-session-projection.ts) enumerates the same - * worktree/tab-scoped fields by worktree-path; the two surfaces are kept - * deliberately aligned — when a new worktree-scoped field is added there it - * must be classified here too. + * workspace-session.ts. */ export type HostSessionSlices = Partial> diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts index f803b6f17d2f..f7a4dd385cb5 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts @@ -1,27 +1,16 @@ /** - * Boot hydration and the remote-workspace round trip for a worktree whose session the - * main-process runtime wrote into `ssh:`. + * Boot hydration for a worktree whose session the main-process runtime wrote into `ssh:`. * - * The renderer used to read only the local + `runtime:*` partitions, so those tabs were invisible; - * the export then published an explicit empty list, `replace-session` made it authoritative, and - * the next pull applied it as a deletion that re-poisoned the snapshot on every launch + * The renderer used to read only the local + `runtime:*` partitions, so those tabs were invisible * (#12721, #18173). - * - * Runs the real projection and the real pull-side merge — the failure only exists where the read, - * the publish and the merge meet, and each of them is individually self-consistent. */ import { describe, expect, it } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { getDefaultWorkspaceSession } from '../../../shared/constants' import type { ExecutionHostId } from '../../../shared/execution-host' -import { - exportRemoteWorkspaceSession, - importRemoteWorkspaceSession -} from '../../../shared/remote-workspace-session-projection' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' -import { mergeDirectSshRemoteWorkspaceSession } from '../hooks/remote-workspace-session-merge' import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' @@ -346,8 +335,7 @@ describe('ssh host partition workspaces with no terminal tabs', () => { }) it('restores an unsaved hot-exit draft, which no other channel can recover', async () => { - // RemoteWorkspaceSession carries terminal fields only, so the SSH host snapshot cannot - // round-trip editor state. Losing it here loses user-authored content outright. + // No other channel carries editor state, so losing it here loses user-authored content outright. const read = await fetchWorkspaceSessionWithRuntimeHostOwners( partitionedApi(editorOnlyPartitions()), repos @@ -436,8 +424,8 @@ describe('ssh host partition rows the host has nothing for', () => { it('does not let an empty host row destroy an unsaved draft the base alone holds', async () => { // The host having no open files is not evidence the base's are gone. Losing this is worse than - // the bug the adoption exists to fix: RemoteWorkspaceSession carries terminal fields only, so - // nothing can recover a `dirtyDraftContent` once the read has dropped it. + // the bug the adoption exists to fix: nothing can recover a `dirtyDraftContent` once the read + // has dropped it. const read = await fetchWorkspaceSessionWithRuntimeHostOwners( partitionedApi(emptyHostRowsOverBaseDraft(true)), repos @@ -785,92 +773,4 @@ describe('ssh host partition and the closed-last-terminal tombstone', () => { ) ).toBe(false) }) - - it('publishes the tombstone rather than a row the host can read as unknown', async () => { - // Rule 3, client-publishes -> other-client-reads. An emptied workspace must publish its empty - // list so a paired client sees the same state; the merge's `hostUnknown` defence covers tabs - // this client holds, and an empty row is exactly what it holds here. - const { restored } = await roundTripSession(session({ tabsByWorktree: { [WORKTREE_ID]: [] } })) - const published = exportRemoteWorkspaceSession(restored, { - isTargetWorktree: (worktreeId) => worktreeId === WORKTREE_ID - }) - - expect(published.tabsByWorktreePath[WORKTREE_PATH]).toEqual([]) - }) -}) - -describe('ssh host partition remote-workspace round trip', () => { - it('does not delete the worktree tabs across a publish and the next pull', async () => { - const partitions = strandedPartitions([tab('tab-runtime')]) - const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos) - // Publish exactly what the renderer now holds, then apply it back as `replace-session` does. - const published = exportRemoteWorkspaceSession(read.session, { - isTargetWorktree: (worktreeId) => worktreeId === WORKTREE_ID - }) - const pulled = importRemoteWorkspaceSession(published, { - resolveWorktreeId: (worktreePath) => (worktreePath === WORKTREE_PATH ? WORKTREE_ID : null), - executionHostId: SSH_HOST_ID - }) - - const merged = mergeDirectSshRemoteWorkspaceSession( - read.session, - pulled, - new Set([WORKTREE_ID]), - read.session.tabsByWorktree, - new Set(), - SSH_HOST_ID - ) - - expect(merged.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-runtime']) - }) - - it('keeps the tabs when an older client publishes an empty list for them', async () => { - // Rule 3 skew, the dangerous direction: a client that predates this fix still reads only the - // local partition, so its own `replace-session` names this workspace's path with NO tabs. The - // merge already refuses to delete what the host has never been told about — but only for tabs - // this client actually holds, which before the fix it did not. Hydrating them is what arms - // that defence, and this client then republishes the real list and repairs the snapshot. - const read = await fetchWorkspaceSessionWithRuntimeHostOwners( - partitionedApi(strandedPartitions([tab('tab-runtime')])), - repos - ) - const publishedByOldClient = importRemoteWorkspaceSession( - { - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: { [WORKTREE_PATH]: [] }, - terminalLayoutsByTabId: {} - }, - { - resolveWorktreeId: (worktreePath) => (worktreePath === WORKTREE_PATH ? WORKTREE_ID : null), - executionHostId: SSH_HOST_ID - } - ) - - const merged = mergeDirectSshRemoteWorkspaceSession( - read.session, - publishedByOldClient, - new Set([WORKTREE_ID]), - read.session.tabsByWorktree, - new Set(), - SSH_HOST_ID - ) - - expect(merged.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-runtime']) - }) - - it('publishes the stranded tabs rather than an empty list', async () => { - const read = await fetchWorkspaceSessionWithRuntimeHostOwners( - partitionedApi(strandedPartitions([tab('tab-runtime')])), - repos - ) - - const published = exportRemoteWorkspaceSession(read.session, { - isTargetWorktree: (worktreeId) => worktreeId === WORKTREE_ID - }) - - expect(published.tabsByWorktreePath[WORKTREE_PATH]?.map((entry) => entry.id)).toEqual([ - 'tab-runtime' - ]) - }) }) diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts index 806b7840ce5a..71ca6b3473e6 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts @@ -65,6 +65,15 @@ function seedDirectSsh(store: TestStore): void { }) } +function connectTarget(store: TestStore): void { + store.getState().setSshConnectionState(TARGET_ID, { + targetId: TARGET_ID, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) +} + function terminalTabCount(store: TestStore, worktreeId: string): number { return (store.getState().tabsByWorktree[worktreeId] ?? []).length } @@ -86,102 +95,42 @@ describe('workspace terminal seeding authority', () => { expect(terminalTabCount(store, LOCAL_WORKTREE_ID)).toBe(1) }) - it('distinguishes an unanswered SSH host from one that answered with nothing', () => { + it('seeds once the SSH target connects, with no workspace snapshot to wait for', () => { + // The relay that served host workspace snapshots is gone (#27214), so nothing will ever answer + // one; waiting for it left a reconnected target unable to seed or resume for good. const store = createTestStore() seedDirectSsh(store) - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( 'unverifiable' ) - expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeNull() - expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0) - // The host answers and holds nothing here. That is positive evidence, so the workspace seeds. - store.getState().markRemoteWorkspaceHydrated(TARGET_ID) + connectTarget(store) expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy() expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) }) - it.each(['offline', 'error'] as const)( - 'falls back to none when a sync terminates in %s without ever hydrating', - (phase) => { - // The regression this guards: remoteWorkspaceHydratedTargetIds is add-only in practice - // (clearRemoteWorkspaceHydrated has no production caller), so without a floor one failed sync - // leaves every git worktree on this target terminal-less and its sleeping agents unresumable - // for the rest of the app session — strictly worse than the pre-gate behaviour, and escapable - // only by creating a tab by hand. - const store = createTestStore() - seedDirectSsh(store) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase, direction: 'pull' }) - - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') - expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy() - expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) - } - ) - - it('still declines while a sync is in flight', () => { - // The floor must not swallow the wait it exists to bound: 'pulling' is an attempt in progress, - // not one that terminated without an answer. + it('stops seeding when the SSH target drops', () => { const store = createTestStore() seedDirectSsh(store) - store - .getState() - .setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'pulling', direction: 'pull' }) - - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( - 'unverifiable' - ) - expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeNull() - }) - - it('keeps a hydrated target on none even if a later sync errors', () => { - // Once the host has answered, a subsequent transport error is not evidence it holds nothing new - // — but it is also not a reason to start refusing. The hydrated branch wins. - const store = createTestStore() - seedDirectSsh(store) - store.getState().markRemoteWorkspaceHydrated(TARGET_ID) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'error', direction: 'pull' }) - - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') - }) - - it('treats a conflicting host snapshot as unanswered', () => { - const store = createTestStore() - seedDirectSsh(store) - store.getState().markRemoteWorkspaceHydrated(TARGET_ID) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'conflict' }) + connectTarget(store) + store.getState().setSshConnectionState(TARGET_ID, { + targetId: TARGET_ID, + status: 'reconnecting', + error: null, + reconnectAttempt: 1 + }) expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( 'unverifiable' ) expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeNull() - expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0) - }) - - it('seeds a worktree the conflicting snapshot placed, and only that one', () => { - const store = createTestStore() - seedDirectSsh(store) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { - phase: 'conflict', - direction: 'pull', - unplacedTabWorktreePaths: ['/srv/proj/another-machines-worktree'] - }) - - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none') - expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy() - expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1) }) - it('leaves a worktree emptied on purpose empty at startup, even once the host answered for it', () => { + it('leaves a worktree emptied on purpose empty at startup, even once its target connected', () => { const store = createTestStore() seedDirectSsh(store) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { - phase: 'conflict', - direction: 'pull', - unplacedTabWorktreePaths: ['/srv/proj/another-machines-worktree'] - }) + connectTarget(store) store.setState({ tabsByWorktree: { [SSH_WORKTREE_ID]: [] }, closedTerminalTabTombstonesByTabId: { @@ -205,39 +154,6 @@ describe('workspace terminal seeding authority', () => { ).toBeTruthy() }) - it.each([ - ['its own path', '/srv/proj/feature'], - ['another spelling of its path', '/srv/proj//feature/'] - ])('keeps waiting when the host named tabs it could not place on %s', (_label, unplacedPath) => { - const store = createTestStore() - seedDirectSsh(store) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { - phase: 'conflict', - direction: 'pull', - unplacedTabWorktreePaths: [unplacedPath] - }) - - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( - 'unverifiable' - ) - }) - - it('stops trusting a placed worktree once a later sync event replaces the conflict', () => { - const store = createTestStore() - seedDirectSsh(store) - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { - phase: 'conflict', - direction: 'pull', - unplacedTabWorktreePaths: [] - }) - // What a snapshot that could not be applied, or a rejected upload, writes. - store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'conflict' }) - - expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe( - 'unverifiable' - ) - }) - it('leaves terminal creation to the host of a paired runtime workspace', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.ts b/src/renderer/src/lib/workspace-terminal-host-authority.ts index 9068280c4379..cf970d17dba1 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.ts @@ -1,10 +1,8 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseExecutionHostId } from '../../../shared/execution-host' -import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { splitWorktreeId } from '../../../shared/worktree/id' import type { HostLiveTerminalProbeVerdict } from '@/runtime/host-live-terminal-probe' -import type { RemoteWorkspaceSyncStatus } from '@/store/slices/ssh' +import type { SshConnectionState } from '../../../shared/ssh-types' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' import { getExecutionHostIdForWorktree, @@ -18,13 +16,11 @@ import { * than restated so the two cannot drift: * * - `live` — a remote execution host owns terminal creation here. It supplies the surface itself. - * - `unverifiable` — the workspace has a remote execution host that has not answered for it: a sync - * still in flight or not yet attempted, or a snapshot naming tabs here that could not be placed. - * It is bounded: a sync that terminates without an answer resolves `none` rather than refusing - * forever (see resolveDirectSshAuthority). + * - `unverifiable` — the workspace has a remote execution host that has not answered for it: a + * runtime owner that cannot be named yet, or an SSH target that is not connected. * - `none` — no remote host holds terminals here: either the workspace is local (this client *is* - * the execution host, and its own tab rows are the whole truth) or the host answered and holds - * nothing. + * the execution host, and its own tab rows are the whole truth) or its SSH target is connected + * and serves no host-side workspace for this row to wait on. * * `unverifiable` is a verdict, never a synonym for `none`. Two client behaviours read local rows as * the verdict on what the host is running, and both are wrong before it answers: @@ -39,57 +35,18 @@ import { export type WorkspaceTerminalHostAuthority = HostLiveTerminalProbeVerdict export type WorkspaceTerminalHostAuthorityState = WorktreeRuntimeOwnerState & { - remoteWorkspaceHydratedTargetIds?: ReadonlySet - remoteWorkspaceSyncStatusByTargetId?: Record + sshConnectionStates?: ReadonlyMap } -/** A sync attempt that has stopped without an answer. `unverifiable` is the honest verdict about the - * host, but holding it forever is not a verdict — it is a refusal to act, so nothing would ever - * lift it. Four paths reach here: local-hydration timeout, a null `remoteWorkspace.get`, a falsy - * apply token, and never connecting at all. - * - * Known gap: this floor was reasoned about when hydration was add-only, so "un-hydrated" implied - * "the host never answered". A snapshot whose rows could not be placed now revokes hydration - * (remote-workspace-snapshot-apply.ts), so a target that later lands on `offline`/`error` reaches - * this floor having *demonstrably* answered with tabs. Seeding is then authorised over live host - * terminals. That is not a regression — before the revocation existed the same target was marked - * hydrated and `synced`, which reached `none` sooner — but the floor should learn to tell a - * revoked target from one that never answered. Tracked for the SSH-v3 consolidation, where a - * single authoritative liveness source replaces this pair. */ -const TERMINATED_WITHOUT_ANSWER_PHASES = new Set(['offline', 'error']) - +/** Why connection, not a host snapshot: managed hosts publish their tabs through runtime + * session tabs, and nothing answers a host workspace snapshot for an `ssh:` row since the relay + * was removed (#27214). Waiting for one would refuse forever. */ function resolveDirectSshAuthority( state: WorkspaceTerminalHostAuthorityState, - targetId: string, - worktreeId: string + targetId: string ): WorkspaceTerminalHostAuthority { - const status = state.remoteWorkspaceSyncStatusByTargetId?.[targetId] - const phase = status?.phase - if (state.remoteWorkspaceHydratedTargetIds?.has(targetId)) { - // Why: the same pair use-app-session-persistence.ts gates uploads on. A conflicting snapshot - // means the client's picture is not the host's, so it is no basis for deciding the host holds - // nothing. - return phase === 'conflict' ? 'unverifiable' : 'none' - } - if (phase !== undefined && TERMINATED_WITHOUT_ANSWER_PHASES.has(phase)) { - // The bounded floor. Without it a single failed sync leaves every git worktree on this target - // terminal-less and its sleeping agents unresumable for the rest of the app session — strictly - // worse than the pre-gate behaviour, and only escapable by creating a tab by hand. Declining to - // seed is meant to be a wait, not a permanent refusal. - return 'none' - } - const unplacedPaths = status?.unplacedTabWorktreePaths - const worktreePath = splitWorktreeId(worktreeId)?.worktreePath - if (phase === 'conflict' && unplacedPaths && worktreePath) { - // Why: that snapshot was applied to every worktree it could place, so the host has answered for - // this one. Refusing the whole target strands it on one path this client never had (#22015). - const comparablePath = normalizeRuntimePathForComparison(worktreePath) - return unplacedPaths.some((path) => normalizeRuntimePathForComparison(path) === comparablePath) - ? 'unverifiable' - : 'none' - } - // Not connected, still pulling, or not yet attempted — "we could not ask", never "nothing there". - return 'unverifiable' + // Not connected is "we could not ask", never "nothing there". + return state.sshConnectionStates?.get(targetId)?.status === 'connected' ? 'none' : 'unverifiable' } /** @@ -116,10 +73,8 @@ export function resolveWorkspaceTerminalHostAuthority( return 'unverifiable' } if (host?.kind === 'ssh' && parseWorkspaceKey(worktreeId)?.type !== 'folder') { - // Why the git-worktree narrowing: the snapshot replaces exactly DirectSshTargetScope.gitWorktreeIds - // (remote-workspace-snapshot-apply.ts). A folder workspace's rows are never replaced by the host, - // so waiting on an answer that will never name them would leave it terminal-less for good. - return resolveDirectSshAuthority(state, host.targetId, worktreeId) + // Folder workspaces were never host-replaced, so they keep `none`. + return resolveDirectSshAuthority(state, host.targetId) } // Local, or outside the host's replace scope: this client is the execution host and its own rows are // the whole truth. Absence of a catalog row is not evidence of a remote owner, and refusing to act @@ -139,14 +94,13 @@ const AUTHORITY_INPUT_KEYS = [ 'detectedWorktreesByRepo', 'folderWorkspaces', 'projectGroups', - 'remoteWorkspaceHydratedTargetIds', - 'remoteWorkspaceSyncStatusByTargetId', 'removedRuntimeEnvironmentIds', 'repos', 'restoredRuntimeHostIdByWorkspaceSessionKey', 'runtimeEnvironmentCatalogHydrated', 'runtimeEnvironments', 'settings', + 'sshConnectionStates', 'worktreesByRepo' ] as const satisfies readonly (keyof WorkspaceTerminalHostAuthorityState)[] diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts index 6bf8139cb0df..74513cba19f1 100644 --- a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -317,12 +317,14 @@ describe('worktree agent activation seam', () => { expect(listSessions).not.toHaveBeenCalled() }) - it('blocks seeding while detached and allows activation after the owning relay answers', async () => { + it('blocks seeding while detached and allows activation once the SSH target is connected', async () => { const worktree = makeWorktree() useAppStore.setState({ ...baseState(), worktreesByRepo: { [worktree.repoId]: [{ ...worktree, hostId: 'ssh:box' }] }, - remoteWorkspaceSyncStatusByTargetId: { box: { phase: 'offline' } as never } + sshConnectionStates: new Map([ + ['box', { targetId: 'box', status: 'connected', error: null, reconnectAttempt: 0 }] + ]) }) const { listSessions } = stubInventory() listSessions.mockImplementation(async (scope?: unknown) => { diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts index 05fe4f61aa82..35053548beed 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts @@ -129,19 +129,6 @@ describe('SSH target cleanup tab map', () => { store.getState().clearRemovedSshTargetState('removed') expect(store.getState().tabsByWorktree).toBe(tabsByWorktree) }) - - it('clears pending split edits owned by the removed target', () => { - const store = createTestStore() - const removed = { targetId: 'removed', root: null } - const retained = { targetId: 'other', root: null } - store.setState({ - pendingDirectSshLayoutEditsByTabId: { removedTab: removed, otherTab: retained } - }) - - const patch = buildRemovedSshTargetCleanupPatch(store.getState(), 'removed') - - expect(patch?.pendingDirectSshLayoutEditsByTabId).toEqual({ otherTab: retained }) - }) }) describe('sshConnectionStatesEqual', () => { diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 515575b80423..efb4375f5225 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -106,11 +106,6 @@ function withoutMapKey(map: Map, key: string): Map | un return next.delete(key) ? next : undefined } -function withoutSetKey(set: Set, key: string): Set | undefined { - const next = new Set(set) - return next.delete(key) ? next : undefined -} - function filterIfChanged(items: T[], keep: (item: T) => boolean): T[] | undefined { const next = items.filter(keep) return next.length === items.length ? undefined : next @@ -251,13 +246,6 @@ export function buildRemovedSshTargetCleanupPatch( 'directSshPaneRetryHistoryByTabId', omitRemovedSshTargetRecovery(state.directSshPaneRetryHistoryByTabId, targetId, targetTabIds) ) - assign( - 'pendingDirectSshLayoutEditsByTabId', - filterRecordIfChanged( - state.pendingDirectSshLayoutEditsByTabId ?? {}, - (_tabId, entry) => entry.targetId !== targetId - ) - ) assign( 'deferredSshReconnectTargets', filterIfChanged(state.deferredSshReconnectTargets, (id) => id !== targetId) @@ -270,10 +258,6 @@ export function buildRemovedSshTargetCleanupPatch( 'transientClearedAgentStatusConnectionIds', omitKey(state.transientClearedAgentStatusConnectionIds, targetId) ) - assign( - 'remoteWorkspaceSyncStatusByTargetId', - omitKey(state.remoteWorkspaceSyncStatusByTargetId, targetId) - ) assign('portForwardsByConnection', omitKey(state.portForwardsByConnection, targetId)) assign('detectedPortsByConnection', omitKey(state.detectedPortsByConnection, targetId)) assign('sshConnectionStates', withoutMapKey(state.sshConnectionStates, targetId)) @@ -281,9 +265,5 @@ export function buildRemovedSshTargetCleanupPatch( // Why: a lingering generation would keep a deleted registration fenceable, and // the id is reissued fresh on re-add, so the old value can never become right. assign('sshTargetGenerations', withoutMapKey(state.sshTargetGenerations, targetId)) - assign( - 'remoteWorkspaceHydratedTargetIds', - withoutSetKey(state.remoteWorkspaceHydratedTargetIds, targetId) - ) return Object.keys(patch).length > 0 ? patch : null } diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index 6a24ba58954e..ce4dfd177503 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -75,11 +75,6 @@ describe('createSshSlice', () => { [targetId, 'Removed target'], [otherTargetId, 'Other target'] ]), - remoteWorkspaceHydratedTargetIds: new Set([targetId, otherTargetId]), - remoteWorkspaceSyncStatusByTargetId: { - [targetId]: { phase: 'offline' }, - [otherTargetId]: { phase: 'synced' } - }, portForwardsByConnection: { [targetId]: [ { @@ -170,8 +165,6 @@ describe('createSshSlice', () => { const state = store.getState() expect(state.sshConnectionStates.has(targetId)).toBe(false) expect(state.sshTargetLabels.has(targetId)).toBe(false) - expect(state.remoteWorkspaceHydratedTargetIds.has(targetId)).toBe(false) - expect(state.remoteWorkspaceSyncStatusByTargetId[targetId]).toBeUndefined() expect(state.portForwardsByConnection[targetId]).toBeUndefined() expect(state.detectedPortsByConnection[targetId]).toBeUndefined() expect(state.sshCredentialQueue.map((req) => req.targetId)).toEqual([otherTargetId]) @@ -203,8 +196,6 @@ describe('createSshSlice', () => { expect(state.sshConnectionStates.get(otherTargetId)?.status).toBe('connected') expect(state.sshTargetLabels.get(otherTargetId)).toBe('Other target') - expect(state.remoteWorkspaceHydratedTargetIds.has(otherTargetId)).toBe(true) - expect(state.remoteWorkspaceSyncStatusByTargetId[otherTargetId]).toEqual({ phase: 'synced' }) expect(state.portForwardsByConnection[otherTargetId]).toHaveLength(1) expect(state.detectedPortsByConnection[otherTargetId]).toHaveLength(1) expect(state.tabsByWorktree[otherWorktreeId][0]?.ptyId).toBe(otherPtyId) @@ -546,8 +537,6 @@ describe('createSshSlice', () => { ] ]) const sshTargetLabels = new Map([['ssh-2', 'Other']]) - const remoteWorkspaceHydratedTargetIds = new Set(['ssh-2']) - const remoteWorkspaceSyncStatusByTargetId = { 'ssh-2': { phase: 'synced' as const } } const portForwardsByConnection = { 'ssh-2': [] } const detectedPortsByConnection = { 'ssh-2': [] } const sshCredentialQueue = [ @@ -559,8 +548,6 @@ describe('createSshSlice', () => { store.setState({ sshConnectionStates, sshTargetLabels, - remoteWorkspaceHydratedTargetIds, - remoteWorkspaceSyncStatusByTargetId, portForwardsByConnection, detectedPortsByConnection, sshCredentialQueue, @@ -574,8 +561,6 @@ describe('createSshSlice', () => { expect(state.deferredSshReconnectTargets).toEqual([]) expect(state.sshConnectionStates).toBe(sshConnectionStates) expect(state.sshTargetLabels).toBe(sshTargetLabels) - expect(state.remoteWorkspaceHydratedTargetIds).toBe(remoteWorkspaceHydratedTargetIds) - expect(state.remoteWorkspaceSyncStatusByTargetId).toBe(remoteWorkspaceSyncStatusByTargetId) expect(state.portForwardsByConnection).toBe(portForwardsByConnection) expect(state.detectedPortsByConnection).toBe(detectedPortsByConnection) expect(state.sshCredentialQueue).toBe(sshCredentialQueue) diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index c304fedf7cb0..337cb93baa1f 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -15,19 +15,6 @@ import { import { sshConnectionStatesEqual } from './ssh-connection-state-equality' import { createBoundedGenerationMap } from '../../lib/bounded-generation-map' -export type RemoteWorkspaceSyncStatus = { - phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline' - direction?: 'pull' | 'push' - revision?: number - updatedAt?: number - hostObservationToken?: string - lastSyncedAt?: number - message?: string - /** Set only by an applied pull that ends in `conflict`: the host paths whose tabs this client - * could not place. Every other worktree on the target holds the host's rows from that snapshot. */ - unplacedTabWorktreePaths?: readonly string[] -} - export type SshCredentialRequest = { requestId: string targetId: string @@ -62,8 +49,6 @@ export type SshSlice = { * client on a host without the ssh RPC), so absence from sshTargetLabels * only counts as removal evidence when this is set. */ sshTargetsHydrated: boolean - remoteWorkspaceHydratedTargetIds: Set - remoteWorkspaceSyncStatusByTargetId: Record sshCredentialQueue: SshCredentialRequest[] /** Incremented when an SSH target transitions to 'connected'. Allows * components like the file explorer to re-trigger data loads that failed @@ -82,9 +67,6 @@ export type SshSlice = { setRemovedSshTargetLabels: (labels: Record) => void setSshTargetsMetadata: (targets: SshTargetSummary[]) => void clearRemovedSshTargetState: (targetId: string) => void - markRemoteWorkspaceHydrated: (targetId: string) => void - clearRemoteWorkspaceHydrated: (targetId: string) => void - setRemoteWorkspaceSyncStatus: (targetId: string, status: RemoteWorkspaceSyncStatus) => void enqueueSshCredentialRequest: (req: SshCredentialRequest) => void removeSshCredentialRequest: (requestId: string) => void setPortForwards: (targetId: string, forwards: PortForwardEntry[]) => void @@ -104,8 +86,6 @@ export const createSshSlice: StateCreator = (set) => sshTargetGenerations: new Map(), removedSshTargetLabels: new Map(), sshTargetsHydrated: false, - remoteWorkspaceHydratedTargetIds: new Set(), - remoteWorkspaceSyncStatusByTargetId: {}, sshCredentialQueue: [], sshConnectedGeneration: 0, portForwardsByConnection: {}, @@ -159,25 +139,6 @@ export const createSshSlice: StateCreator = (set) => }), clearRemovedSshTargetState: (targetId) => set((s) => buildRemovedSshTargetCleanupPatch(s, targetId) ?? s), - markRemoteWorkspaceHydrated: (targetId) => - set((s) => { - const next = new Set(s.remoteWorkspaceHydratedTargetIds) - next.add(targetId) - return { remoteWorkspaceHydratedTargetIds: next } - }), - clearRemoteWorkspaceHydrated: (targetId) => - set((s) => { - const next = new Set(s.remoteWorkspaceHydratedTargetIds) - next.delete(targetId) - return { remoteWorkspaceHydratedTargetIds: next } - }), - setRemoteWorkspaceSyncStatus: (targetId, status) => - set((s) => ({ - remoteWorkspaceSyncStatusByTargetId: { - ...s.remoteWorkspaceSyncStatusByTargetId, - [targetId]: status - } - })), enqueueSshCredentialRequest: (req) => set((s) => ({ sshCredentialQueue: [...s.sshCredentialQueue, req] })), removeSshCredentialRequest: (requestId) => diff --git a/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts b/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts index 0e0e13f80441..6e0331d991db 100644 --- a/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts +++ b/src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createTestStore, makeTab, seedStore, makeWorktree } from './store-test-helpers' +import { createTestStore, makeTab, seedStore } from './store-test-helpers' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' { @@ -310,75 +310,6 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter unsubscribe() expect(wakeups).toBe(0) }) - - it('guards SSH acknowledgements by captured edit identity and target', () => { - const store = createTestStore() - const worktreeA = 'repo-a::/tmp/a' - const worktreeB = 'repo-b::/tmp/b' - store.setState({ - repos: [ - { - id: 'repo-a', - path: '/tmp', - displayName: 'Repo A', - badgeColor: '#000', - addedAt: 0, - connectionId: 'target-a' - }, - { - id: 'repo-b', - path: '/tmp', - displayName: 'Repo B', - badgeColor: '#000', - addedAt: 0, - connectionId: 'target-b' - } - ], - worktreesByRepo: { - 'repo-a': [makeWorktree({ id: worktreeA, repoId: 'repo-a', hostId: 'ssh:target-a' })], - 'repo-b': [makeWorktree({ id: worktreeB, repoId: 'repo-b', hostId: 'ssh:target-b' })] - }, - tabsByWorktree: { - [worktreeA]: [makeTab({ id: 'tab-a', worktreeId: worktreeA })], - [worktreeB]: [makeTab({ id: 'tab-b', worktreeId: worktreeB })] - } - }) - const empty = { root: null, activeLeafId: null, expandedLeafId: null } - - store.getState().setTabLayout('tab-a', { - ...empty, - root: { type: 'leaf', leafId: 'leaf-initial' }, - activeLeafId: 'leaf-initial' - }) - store.getState().setTabLayout('tab-a', empty) - const firstEdit = store.getState().pendingDirectSshLayoutEditsByTabId['tab-a'] - store.getState().setTabLayout('tab-a', { - ...empty, - root: { type: 'leaf', leafId: 'leaf-a' }, - activeLeafId: 'leaf-a' - }) - const currentEdit = store.getState().pendingDirectSshLayoutEditsByTabId['tab-a'] - expect(currentEdit).not.toBe(firstEdit) - - store.getState().acknowledgeDirectSshLayoutEdits({ 'tab-a': firstEdit }) - expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(currentEdit) - - store.getState().setTabLayout('tab-b', { - ...empty, - root: { type: 'leaf', leafId: 'leaf-initial-b' }, - activeLeafId: 'leaf-initial-b' - }) - store.getState().setTabLayout('tab-b', empty) - const targetBEdit = store.getState().pendingDirectSshLayoutEditsByTabId['tab-b'] - store.getState().acknowledgeDirectSshLayoutEdits({ 'tab-a': currentEdit }) - expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-a']).toBeUndefined() - expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-b']).toBe(targetBEdit) - - store.getState().acknowledgeDirectSshLayoutEdits({ - 'tab-b': { targetId: 'target-a', root: targetBEdit.root } - }) - expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-b']).toBe(targetBEdit) - }) }) } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5b2137120342..5a50d46c2e2a 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -43,7 +43,6 @@ export const createTerminalSlice: StateCreator expandedPaneByTabId: {}, canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, - pendingDirectSshLayoutEditsByTabId: {}, localOnlyScrollbackByTabId: {}, pendingStartupByTabId: {}, pendingInitialCwdByTabId: {}, diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index edb6d228a9f1..202ad814a457 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -61,7 +61,6 @@ export function applyRemoveWorktreeSuccessState( nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId), unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds), terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), - pendingDirectSshLayoutEditsByTabId: omitByTabId(s.pendingDirectSshLayoutEditsByTabId), localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId), diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index bfca90330888..14ff7da80b8a 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -141,7 +141,6 @@ export function buildWorktreePurgeState( workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey), tabsByWorktree: omitByWorktree(s.tabsByWorktree), terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), - pendingDirectSshLayoutEditsByTabId: omitByTabId(s.pendingDirectSshLayoutEditsByTabId), localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId), runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 4a30b52a2d79..aa5f6f5099d3 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,5 +1,5 @@ import type { AgentAttentionUnreadReason } from '@/attention/agent-attention-contract' -import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' +import type { TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -197,7 +197,6 @@ export type TerminalActions = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void - acknowledgeDirectSshLayoutEdits: (uploaded: Readonly>) => void /** Client-local park scrollback for a tab. Never uploaded; read via `resolveLeafScrollback`. */ setTabLocalOnlyScrollback: (tabId: string, buffersByLeafId: Record | null) => void syncPaneDetachPtyOwnership: (args: { diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 455e73eb0b5f..4e33ce87fc68 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -1,7 +1,5 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' -import { terminalLayoutEqual, terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' -import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' -import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' +import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' import { normalizeTerminalLayoutPtyOwnership, resolveTerminalLayoutPtyOwnershipTransfers @@ -25,7 +23,6 @@ export function createTerminalLayoutActions( | 'setTabPaneExpanded' | 'setTabCanExpandPane' | 'setTabLayout' - | 'acknowledgeDirectSshLayoutEdits' | 'setTabLocalOnlyScrollback' | 'syncPaneDetachPtyOwnership' > { @@ -78,19 +75,6 @@ export function createTerminalLayoutActions( : { canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } } ) }, - acknowledgeDirectSshLayoutEdits: (uploaded) => { - set((s) => { - const acknowledged = Object.keys(uploaded).filter((tabId) => { - const pending = s.pendingDirectSshLayoutEditsByTabId[tabId] - const candidate = uploaded[tabId] - return pending === candidate - }) - const pending = omitRecordKeys(s.pendingDirectSshLayoutEditsByTabId, acknowledged) - return pending === s.pendingDirectSshLayoutEditsByTabId - ? s - : { pendingDirectSshLayoutEditsByTabId: pending } - }) - }, setTabLayout: (tabId, layout) => { let ownershipTransfers: ReturnType = [] set((s) => { @@ -100,13 +84,7 @@ export function createTerminalLayoutActions( } const next = { ...s.terminalLayoutsByTabId } delete next[tabId] - return { - terminalLayoutsByTabId: next, - pendingDirectSshLayoutEditsByTabId: omitRecordKeys( - s.pendingDirectSshLayoutEditsByTabId, - [tabId] - ) - } + return { terminalLayoutsByTabId: next } } const normalized = normalizeTerminalLayoutPtyOwnership(layout) // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched. @@ -121,23 +99,8 @@ export function createTerminalLayoutActions( if (existing && terminalLayoutEqual(existing, normalized.snapshot)) { return s } - const structuralEdit = !terminalLayoutNodeEqual(existing?.root, normalized.snapshot.root) - const workspaceId = structuralEdit - ? Object.keys(s.tabsByWorktree).find((id) => - s.tabsByWorktree[id].some((tab) => tab.id === tabId) - ) - : undefined - const tracksRemoteEdit = workspaceId && getConnectionIdFromState(s, workspaceId) return { - terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot }, - ...(tracksRemoteEdit - ? { - pendingDirectSshLayoutEditsByTabId: { - ...s.pendingDirectSshLayoutEditsByTabId, - [tabId]: { targetId: tracksRemoteEdit, root: normalized.snapshot.root } - } - } - : {}) + terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot } } }) transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers) diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index d0595b8721f8..5a4b097f057d 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -23,11 +23,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' -export type DirectSshLayoutEdit = { - targetId: string - root: TerminalLayoutSnapshot['root'] -} - export type TerminalState = { tabsByWorktree: Record activeTabId: string | null @@ -49,8 +44,6 @@ export type TerminalState = { expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record - /** Local structural edits remain protected until the owning SSH host acknowledges them. */ - pendingDirectSshLayoutEditsByTabId: Record /** Ordinary-park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see * WorkspaceSessionState.localOnlyScrollbackByTabId. Read via resolveLeafScrollbackBuffers only. */ localOnlyScrollbackByTabId: Record> diff --git a/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts b/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts index 68d75b29af35..67aedebbb218 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts @@ -8,9 +8,6 @@ import { seedStore } from '../slices/store-test-helpers' import { createStoreCascadesMockApi } from '../slices/store-cascades-test-harness' -import { mergeDirectSshRemoteWorkspaceSession } from '@/hooks/remote-workspace-session-merge' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { getDefaultWorkspaceSession } from '../../../../shared/constants' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -294,42 +291,5 @@ vi.mock('@/lib/agent-status', async (importOriginal) => ({ expect(store.getState().closedTerminalTabTombstonesByTabId).toEqual({}) expect(closeTerminalSurface).not.toHaveBeenCalled() }) - - // The race the mirror exists for: a host pull lands after closeTab and before main has answered - // the close intent (which never resolves here). The host still lists the tab. - it('keeps a closed SSH tab closed through a pull that lands before main answers', () => { - closeTerminalSurface.mockReturnValue(new Promise(() => {})) - const store = storeWithBothWorktrees() - const hostStillListing = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [REMOTE_WORKTREE]: [makeTab({ id: 'remote-tab', worktreeId: REMOTE_WORKTREE })] - } - } - const applyPull = (): void => { - const state = store.getState() - const merged = mergeDirectSshRemoteWorkspaceSession( - buildWorkspaceSessionPayload(state), - hostStillListing, - new Set([REMOTE_WORKTREE]), - state.tabsByWorktree, - new Set(), - undefined, - state.closedTerminalTabTombstonesByTabId - ) - const replaceWorkspaceKeys = [REMOTE_WORKTREE] - store.getState().hydrateWorkspaceSession(merged, { replaceWorkspaceKeys }) - store.getState().hydrateTabsSession(merged, { replaceWorkspaceKeys }) - } - - store.getState().closeTab('remote-tab') - applyPull() - // A second pull: once re-added, a live local tab would override its record for good. - applyPull() - - expect(store.getState().tabsByWorktree[REMOTE_WORKTREE]?.map((tab) => tab.id) ?? []).toEqual( - [] - ) - }) }) } diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 9361391a9e67..95843e6c173e 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -229,7 +229,6 @@ export function createTerminalTabCloseActions( expandedPaneByTabId: nextExpanded, canExpandPaneByTabId: nextCanExpand, terminalLayoutsByTabId: nextLayouts, - pendingDirectSshLayoutEditsByTabId: omitByTabId(s.pendingDirectSshLayoutEditsByTabId), localOnlyScrollbackByTabId: nextLocalOnlyScrollback, pendingStartupByTabId: nextPendingStartupByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index ef53641d0dc7..80c6c501b3f6 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -174,11 +174,6 @@ export function targetScopedWorkspaceHydrationPatch( hydrated.activeTabIdByWorktree, workspaceKeys ), - pendingDirectSshLayoutEditsByTabId: Object.fromEntries( - Object.entries(state.pendingDirectSshLayoutEditsByTabId ?? {}).filter( - ([tabId]) => !deletedTargetTabIds.has(tabId) - ) - ), tabsByWorktree: replaceHydratedRecordKeys( state.tabsByWorktree, hydrated.tabsByWorktree, @@ -189,7 +184,7 @@ export function targetScopedWorkspaceHydrationPatch( hydrated.lastVisitedAtByWorktreeId, workspaceKeys ), - // Why local marks win: same write-once rule as mergeDirectSshRemoteWorkspaceSession. + // Why local marks win: the applied mark is write-once. defaultTerminalTabsAppliedByWorktreeId: { ...replaceHydratedRecordKeys( state.defaultTerminalTabsAppliedByWorktreeId, diff --git a/src/shared/remote-workspace-session-projection.test.ts b/src/shared/remote-workspace-session-projection.test.ts deleted file mode 100644 index b0e2c6207bcc..000000000000 --- a/src/shared/remote-workspace-session-projection.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - exportRemoteWorkspaceSession, - importRemoteWorkspaceSession -} from './remote-workspace-session-projection' -import { getDefaultWorkspaceSession } from './constants' -import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' - -describe('remote workspace session projection', () => { - // The transient set this boundary mirrors. `recovery` is the tab's in-flight - // heal, timestamped with THIS machine's clock, and `pendingActivationSpawn` is - // a one-shot mount handoff — neither means anything on another client's row, - // and a foreign `startedAt` would be compared against the reader's Date.now(). - it('strips client-local transient tab fields on the way out', () => { - const session = { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-a', - activeWorktreeId: 'repo-a::/srv/app', - activeTabId: 'tab-1', - tabsByWorktree: { - 'repo-a::/srv/app': [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId: 'repo-a::/srv/app', - title: 'Remote', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - pendingActivationSpawn: true, - recovery: { - attemptedAt: [1_000], - generation: 1, - outcome: 'failed' as const, - startedAt: 1_000, - reason: 'reattach-unverifiable' as const, - tabGeneration: 1 - } - } - ] - }, - terminalLayoutsByTabId: {} - } - - const projected = exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => worktreeId.startsWith('repo-a::') - }) - - const exported = projected.tabsByWorktreePath['/srv/app'][0] as Record - expect(exported.recovery).toBeUndefined() - expect(exported.pendingActivationSpawn).toBeUndefined() - expect(exported.id).toBe('tab-1') - }) - - it('exports terminal state using remote worktree paths instead of local repo ids', () => { - const session = { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-a', - activeWorktreeId: 'repo-a::/srv/app', - activeTabId: 'tab-1', - tabsByWorktree: { - 'repo-a::/srv/app': [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId: 'repo-a::/srv/app', - title: 'Remote', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ], - 'repo-local::/tmp/local': [ - { - id: 'tab-local', - ptyId: 'pty-local', - worktreeId: 'repo-local::/tmp/local', - title: 'Local', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }, - 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null } - }, - remoteSessionIdsByTabId: { - 'tab-1': 'pty-1', - 'tab-local': 'pty-local' - }, - defaultTerminalTabsAppliedByWorktreeId: { - 'repo-a::/srv/app': true as const, - 'repo-local::/tmp/local': true as const - } - } - - const projected = exportRemoteWorkspaceSession(session, { - isTargetWorktree: (worktreeId) => worktreeId.startsWith('repo-a::') - }) - - expect(Object.keys(projected.tabsByWorktreePath)).toEqual(['/srv/app']) - expect(projected.tabsByWorktreePath['/srv/app'][0]).toMatchObject({ - id: 'tab-1', - worktreePath: '/srv/app' - }) - expect(projected.terminalLayoutsByTabId).toEqual({ - 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } - }) - expect(projected.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' }) - expect(projected.defaultTerminalTabsAppliedByWorktreePath).toEqual({ '/srv/app': true }) - }) - - it('imports projected terminal state into this client repo id', () => { - const session = importRemoteWorkspaceSession( - { - activeWorktreePath: '/srv/app', - activeTabId: 'tab-1', - tabsByWorktreePath: { - '/srv/app': [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreePath: '/srv/app', - title: 'Remote', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } - }, - remoteSessionIdsByTabId: { 'tab-1': 'pty-1' }, - defaultTerminalTabsAppliedByWorktreePath: { '/srv/app': true } - }, - { resolveWorktreeId: (path) => (path === '/srv/app' ? 'repo-b::/srv/app' : null) } - ) - - expect(session.activeRepoId).toBe('repo-b') - expect(session.activeWorktreeId).toBe('repo-b::/srv/app') - expect(session.tabsByWorktree['repo-b::/srv/app'][0]).toMatchObject({ - id: 'tab-1', - worktreeId: 'repo-b::/srv/app' - }) - expect(session.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' }) - expect(session.defaultTerminalTabsAppliedByWorktreeId).toEqual({ - 'repo-b::/srv/app': true - }) - }) - - it('imports active worktree metadata even when the worktree has no terminal tabs', () => { - const session = importRemoteWorkspaceSession( - { - activeWorktreePath: '/srv/app', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {}, - activeTabIdByWorktreePath: { '/srv/app': null }, - lastVisitedAtByWorktreePath: { '/srv/app': 456 } - }, - { resolveWorktreeId: (path) => (path === '/srv/app' ? 'repo-b::/srv/app' : null) } - ) - - expect(session.activeRepoId).toBe('repo-b') - expect(session.activeWorktreeId).toBe('repo-b::/srv/app') - expect(session.activeTabIdByWorktree).toEqual({ 'repo-b::/srv/app': null }) - expect(session.lastVisitedAtByWorktreeId).toEqual({ 'repo-b::/srv/app': 456 }) - }) - - it('reports every host path whose terminal tabs could not be placed locally', () => { - const unplaced: [string, number][] = [] - - const session = importRemoteWorkspaceSession( - { - activeWorktreePath: '/srv/app', - activeTabId: null, - tabsByWorktreePath: { - '/srv/app': [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreePath: '/srv/app', - title: 'Placed', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ], - '/srv/gone': [ - { - id: 'tab-2', - ptyId: 'pty-2', - worktreePath: '/srv/gone', - title: 'Unplaced', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 2 - }, - { - id: 'tab-3', - ptyId: 'pty-3', - worktreePath: '/srv/gone', - title: 'Unplaced too', - customTitle: null, - color: null, - sortOrder: 1, - createdAt: 3 - } - ], - '/srv/also-gone': [ - { - id: 'tab-4', - ptyId: 'pty-4', - worktreePath: '/srv/also-gone', - title: 'Unplaced elsewhere', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 4 - } - ], - '/srv/empty-and-gone': [] - }, - terminalLayoutsByTabId: {} - }, - { - resolveWorktreeId: (path) => (path === '/srv/app' ? 'repo-b::/srv/app' : null), - onUnplacedTerminalTabs: (worktreePath, tabCount) => unplaced.push([worktreePath, tabCount]) - } - ) - - // Once per unresolvable path that actually carried tabs: not the resolved path, and not the - // path whose tab array was empty (nothing was lost there). - expect(unplaced).toEqual([ - ['/srv/gone', 2], - ['/srv/also-gone', 1] - ]) - expect(Object.keys(session.tabsByWorktree)).toEqual(['repo-b::/srv/app']) - }) - - it('unions two local repo rows that collapse onto one host path instead of clobbering', () => { - // Duplicate repo rows for one remote checkout are the normal state while a host catalog - // reconciles. `worktreePathFromId` drops the repoId, so both keys project onto '/srv/app'; a - // freshly created empty row iterating last used to publish an empty tab list for a workspace - // the user had panes open in, and the upload is a wholesale replace-session (#15484). - const session = { - ...getDefaultWorkspaceSession(), - activeWorktreeId: 'repo-old::/srv/app', - activeTabId: 'tab-live', - tabsByWorktree: { - 'repo-old::/srv/app': [ - { - id: 'tab-live', - ptyId: 'pty-live', - worktreeId: 'repo-old::/srv/app', - title: 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ], - 'repo-new::/srv/app': [] - }, - activeTabIdByWorktree: { - 'repo-old::/srv/app': 'tab-live', - 'repo-new::/srv/app': null - }, - remoteSessionIdsByTabId: { 'tab-live': 'pty-live' } - } - - const projected = exportRemoteWorkspaceSession(session, { - isTargetWorktree: () => true - }) - - expect( - projected.tabsByWorktreePath['/srv/app']?.map((tab) => tab.id), - 'the empty twin row erased a live pane from the host ledger' - ).toEqual(['tab-live']) - expect(projected.activeTabIdByWorktreePath?.['/srv/app']).toBe('tab-live') - expect(projected.remoteSessionIdsByTabId).toEqual({ 'tab-live': 'pty-live' }) - }) - - it('keeps one row per tab id when colliding local keys share a tab', () => { - const tab = { - id: 'tab-shared', - ptyId: 'pty-shared', - title: 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - const session = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'repo-old::/srv/app': [{ ...tab, worktreeId: 'repo-old::/srv/app' }], - 'repo-new::/srv/app': [{ ...tab, worktreeId: 'repo-new::/srv/app' }] - } - } - - const projected = exportRemoteWorkspaceSession(session, { isTargetWorktree: () => true }) - - expect(projected.tabsByWorktreePath['/srv/app']?.map((row) => row.id)).toEqual(['tab-shared']) - }) - - it('does not report unplaced tabs when every host path resolves', () => { - const unplaced: string[] = [] - - importRemoteWorkspaceSession( - { - activeWorktreePath: '/srv/app', - activeTabId: 'tab-1', - tabsByWorktreePath: { - '/srv/app': [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreePath: '/srv/app', - title: 'Remote', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: {} - }, - { - resolveWorktreeId: (path) => `repo-b::${path}`, - onUnplacedTerminalTabs: (worktreePath) => unplaced.push(worktreePath) - } - ) - - expect(unplaced).toEqual([]) - }) - - // The upload-volume contract behind WorkspaceSessionState.localOnlyScrollbackByTabId: a - // buffer in the shared layout rides the export whole; the same bytes in the local-only home - // never leave the client. 20 tabs x 2 panes at the per-leaf cap is the shape that measured - // ~22 MiB per replace-session when ordinary parks wrote the layout. - describe('scrollback upload volume', () => { - const TABS = 20 - const PANES = 2 - const worktreeId = 'repo-a::/srv/app' - const leafBuffer = 'x'.repeat(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) - - function tabId(index: number): string { - return `tab-${index}` - } - function leafId(tab: number, pane: number): string { - return `${tabId(tab)}:leaf-${pane}` - } - function leafBuffers(tab: number): Record { - return Object.fromEntries( - Array.from({ length: PANES }, (_, pane) => [leafId(tab, pane), leafBuffer]) - ) - } - function sessionWith(home: 'shared' | 'localOnly' | 'none') { - const tabs = Array.from({ length: TABS }, (_, index) => index) - return { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-a', - activeWorktreeId: worktreeId, - activeTabId: tabId(0), - tabsByWorktree: { - [worktreeId]: tabs.map((index) => ({ - id: tabId(index), - ptyId: `pty-${index}`, - worktreeId, - title: `Remote ${index}`, - customTitle: null, - color: null, - sortOrder: index, - createdAt: 1 - })) - }, - terminalLayoutsByTabId: Object.fromEntries( - tabs.map((index) => [ - tabId(index), - { - root: { - type: 'split' as const, - direction: 'horizontal' as const, - ratio: 0.5, - first: { type: 'leaf' as const, leafId: leafId(index, 0) }, - second: { type: 'leaf' as const, leafId: leafId(index, 1) } - }, - activeLeafId: leafId(index, 0), - expandedLeafId: null, - ptyIdsByLeafId: { - [leafId(index, 0)]: `pty-${index}`, - [leafId(index, 1)]: `pty-${index}b` - }, - ...(home === 'shared' ? { buffersByLeafId: leafBuffers(index) } : {}) - } - ]) - ), - ...(home === 'localOnly' - ? { - localOnlyScrollbackByTabId: Object.fromEntries( - tabs.map((index) => [tabId(index), leafBuffers(index)]) - ) - } - : {}) - } - } - function exportedBytes(home: 'shared' | 'localOnly' | 'none'): number { - const projected = exportRemoteWorkspaceSession(sessionWith(home), { - isTargetWorktree: (id) => id === worktreeId - }) - return Buffer.byteLength(JSON.stringify(projected)) - } - - it('ships a shared-layout capture whole, at roughly the raw byte count', () => { - const shared = exportedBytes('shared') - const rawBytes = TABS * PANES * TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT - console.log( - `[upload-volume] shared-layout capture: ${(shared / 1024 / 1024).toFixed(1)} MiB (raw ${(rawBytes / 1024 / 1024).toFixed(1)} MiB)` - ) - expect(shared).toBeGreaterThan(rawBytes) - }) - - it('never ships the local-only home: the export stays at the bufferless baseline', () => { - const baseline = exportedBytes('none') - const localOnly = exportedBytes('localOnly') - console.log( - `[upload-volume] bufferless baseline: ${(baseline / 1024).toFixed(1)} KiB; local-only capture: ${(localOnly / 1024).toFixed(1)} KiB` - ) - expect(localOnly).toBe(baseline) - expect( - JSON.stringify( - exportRemoteWorkspaceSession(sessionWith('localOnly'), { - isTargetWorktree: (id) => id === worktreeId - }) - ) - ).not.toContain('localOnlyScrollbackByTabId') - }) - }) -}) diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts deleted file mode 100644 index 29b3e15cd1d5..000000000000 --- a/src/shared/remote-workspace-session-projection.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { getDefaultWorkspaceSession } from './constants' -import type { RemoteWorkspaceSession, RemoteWorkspaceTerminalTab } from './remote-workspace-types' -import type { TerminalTab } from './terminal-tab-types' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { splitWorktreeId } from './worktree/id' -import type { ExecutionHostId } from './execution-host' -import { - composeWorktreeHostIdentity, - getWorktreeIdFromHostIdentity, - isWorktreeHostIdentity -} from './worktree/host-qualified-identity' - -type ExportOptions = { - isTargetWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => boolean -} - -type ImportOptions = { - resolveWorktreeId: (worktreePath: string) => string | null - /** Host owning the projected snapshot; absent preserves the legacy bare key. */ - executionHostId?: ExecutionHostId - /** - * Called for every host path carrying terminal tabs that `resolveWorktreeId` could not place. - * An unplaceable path is `unverifiable` — the local catalog has not landed yet — never proof the - * row is not ours, so callers must not treat such an import as an authoritative picture of the - * host. See docs/reference/ssh-execution-boundary.md. - */ - onUnplacedTerminalTabs?: (worktreePath: string, tabCount: number) => void -} - -function worktreePathFromId(worktreeId: string): string | null { - return splitWorktreeId(worktreeId)?.worktreePath ?? null -} - -function tabToRemote(tab: TerminalTab, worktreePath: string): RemoteWorkspaceTerminalTab { - // `recovery` joins the transient set for the same reason as - // pendingActivationSpawn: it describes THIS client's in-flight heal, and its - // timestamps are this machine's clock. On another client's row they would be - // compared against a foreign `Date.now()`. Nothing hands an unsanitized - // session to this boundary today; stripping here keeps that from mattering. - const { - worktreeId: _worktreeId, - pendingActivationSpawn: _pendingActivationSpawn, - recovery: _recovery, - ...rest - } = tab - void _worktreeId - void _pendingActivationSpawn - void _recovery - return { ...rest, worktreePath } -} - -function tabToLocal(tab: RemoteWorkspaceTerminalTab, worktreeId: string): TerminalTab { - const { worktreePath: _worktreePath, ...rest } = tab - void _worktreePath - return { ...rest, worktreeId } -} - -export function exportRemoteWorkspaceSession( - session: WorkspaceSessionState, - options: ExportOptions -): RemoteWorkspaceSession { - const tabsByWorktreePath: Record = {} - const terminalTabIds = new Set() - - for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { - if (!options.isTargetWorktree(worktreeId)) { - continue - } - const worktreePath = worktreePathFromId(worktreeId) - if (!worktreePath) { - continue - } - // Why union rather than assignment: `worktreePathFromId` drops the repoId, so two local keys - // for one host path — duplicate repo rows for the same remote checkout, which is the normal - // state while a host catalog reconciles — collapse onto one entry here. Assignment let - // whichever key came last win outright, and an empty twin published an empty tab list for a - // workspace the user had panes open in (#15484). This projection is uploaded as a wholesale - // replace-session, so a clobbered entry deletes those tabs from the host snapshot. The host has - // one workspace at that path, so the union deduped by tab id is the only lossless answer. Same - // collision the `Math.max` below folds for `lastVisitedAtByWorktreePath`. - const merged = tabsByWorktreePath[worktreePath] ?? [] - const alreadyProjected = new Set(merged.map((tab) => tab.id)) - for (const tab of tabs) { - if (alreadyProjected.has(tab.id)) { - continue - } - alreadyProjected.add(tab.id) - terminalTabIds.add(tab.id) - merged.push(tabToRemote(tab, worktreePath)) - } - tabsByWorktreePath[worktreePath] = merged - } - - const activeWorktreePath = - session.activeWorktreeId && options.isTargetWorktree(session.activeWorktreeId) - ? worktreePathFromId(session.activeWorktreeId) - : null - - const activeTabId = - session.activeTabId && terminalTabIds.has(session.activeTabId) ? session.activeTabId : null - - const activeTabIdByWorktreePath: Record = {} - for (const [worktreeId, tabId] of Object.entries(session.activeTabIdByWorktree ?? {})) { - if (!options.isTargetWorktree(worktreeId)) { - continue - } - const worktreePath = worktreePathFromId(worktreeId) - if (worktreePath) { - // Same path collision as the tab lists above: a colliding key's null must not erase the - // active tab the other key named. - const resolved = tabId && terminalTabIds.has(tabId) ? tabId : null - activeTabIdByWorktreePath[worktreePath] = - resolved ?? activeTabIdByWorktreePath[worktreePath] ?? null - } - } - - const lastVisitedAtByWorktreePath: Record = {} - for (const [visitKey, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) { - const worktreeId = isWorktreeHostIdentity(visitKey) - ? getWorktreeIdFromHostIdentity(visitKey) - : visitKey - const executionHostId = isWorktreeHostIdentity(visitKey) - ? (visitKey.slice(0, visitKey.indexOf('|')) as ExecutionHostId) - : undefined - if (!options.isTargetWorktree(worktreeId, executionHostId)) { - continue - } - const worktreePath = worktreePathFromId(worktreeId) - if (worktreePath) { - // Why max: a bare legacy key and its host-qualified twin collapse onto one path. - lastVisitedAtByWorktreePath[worktreePath] = Math.max( - lastVisitedAtByWorktreePath[worktreePath] ?? 0, - timestamp - ) - } - } - - const defaultTerminalTabsAppliedByWorktreePath: Record = {} - for (const worktreeId of Object.keys(session.defaultTerminalTabsAppliedByWorktreeId ?? {})) { - if (!options.isTargetWorktree(worktreeId)) { - continue - } - const worktreePath = worktreePathFromId(worktreeId) - if (worktreePath) { - defaultTerminalTabsAppliedByWorktreePath[worktreePath] = true - } - } - - return { - activeWorktreePath, - activeTabId, - tabsByWorktreePath, - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(session.terminalLayoutsByTabId ?? {}).filter(([tabId]) => - terminalTabIds.has(tabId) - ) - ), - activeWorktreePathsOnShutdown: session.activeWorktreeIdsOnShutdown - ?.filter((worktreeId) => options.isTargetWorktree(worktreeId)) - .map(worktreePathFromId) - .filter((path): path is string => Boolean(path)), - activeTabIdByWorktreePath, - remoteSessionIdsByTabId: session.remoteSessionIdsByTabId - ? Object.fromEntries( - Object.entries(session.remoteSessionIdsByTabId).filter(([tabId]) => - terminalTabIds.has(tabId) - ) - ) - : undefined, - lastVisitedAtByWorktreePath, - defaultTerminalTabsAppliedByWorktreePath - } -} - -export function importRemoteWorkspaceSession( - remote: RemoteWorkspaceSession, - options: ImportOptions -): WorkspaceSessionState { - const session = getDefaultWorkspaceSession() - const tabsByWorktree: Record = {} - const terminalTabIds = new Set() - const worktreeIdByPath = new Map() - const resolvePath = (worktreePath: string): string | null => { - if (worktreeIdByPath.has(worktreePath)) { - return worktreeIdByPath.get(worktreePath) ?? null - } - const worktreeId = options.resolveWorktreeId(worktreePath) - if (worktreeId) { - worktreeIdByPath.set(worktreePath, worktreeId) - } - return worktreeId - } - - for (const [worktreePath, tabs] of Object.entries(remote.tabsByWorktreePath ?? {})) { - const worktreeId = resolvePath(worktreePath) - if (!worktreeId) { - if (tabs.length > 0) { - options.onUnplacedTerminalTabs?.(worktreePath, tabs.length) - } - continue - } - tabsByWorktree[worktreeId] = tabs.map((tab) => { - terminalTabIds.add(tab.id) - return tabToLocal(tab, worktreeId) - }) - } - - const activeWorktreeId = remote.activeWorktreePath ? resolvePath(remote.activeWorktreePath) : null - - const activeTabId = - remote.activeTabId && terminalTabIds.has(remote.activeTabId) ? remote.activeTabId : null - - const activeTabIdByWorktree: Record = {} - for (const [worktreePath, tabId] of Object.entries(remote.activeTabIdByWorktreePath ?? {})) { - const worktreeId = resolvePath(worktreePath) - if (worktreeId) { - activeTabIdByWorktree[worktreeId] = tabId && terminalTabIds.has(tabId) ? tabId : null - } - } - - const lastVisitedAtByWorktreeId: Record = {} - for (const [worktreePath, timestamp] of Object.entries( - remote.lastVisitedAtByWorktreePath ?? {} - )) { - const worktreeId = resolvePath(worktreePath) - if (worktreeId) { - const key = options.executionHostId - ? composeWorktreeHostIdentity(options.executionHostId, worktreeId) - : worktreeId - lastVisitedAtByWorktreeId[key] = timestamp - } - } - - const defaultTerminalTabsAppliedByWorktreeId: Record = {} - for (const worktreePath of Object.keys(remote.defaultTerminalTabsAppliedByWorktreePath ?? {})) { - const worktreeId = resolvePath(worktreePath) - if (worktreeId) { - defaultTerminalTabsAppliedByWorktreeId[worktreeId] = true - } - } - - return { - ...session, - activeRepoId: activeWorktreeId ? (splitWorktreeId(activeWorktreeId)?.repoId ?? null) : null, - activeWorktreeId, - activeTabId, - tabsByWorktree, - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(remote.terminalLayoutsByTabId ?? {}).filter(([tabId]) => - terminalTabIds.has(tabId) - ) - ), - activeWorktreeIdsOnShutdown: remote.activeWorktreePathsOnShutdown - ?.map((path) => worktreeIdByPath.get(path)) - .filter((id): id is string => Boolean(id)), - activeTabIdByWorktree, - remoteSessionIdsByTabId: remote.remoteSessionIdsByTabId - ? Object.fromEntries( - Object.entries(remote.remoteSessionIdsByTabId).filter(([tabId]) => - terminalTabIds.has(tabId) - ) - ) - : undefined, - lastVisitedAtByWorktreeId, - defaultTerminalTabsAppliedByWorktreeId - } -} diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts deleted file mode 100644 index 9544b709f9ca..000000000000 --- a/src/shared/remote-workspace-types.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' - -// Transient client-local fields are omitted, not merely unset: `recovery` is -// this client's in-flight heal, stamped with this machine's clock, so the type -// must not let a future producer put one on the wire. -export type RemoteWorkspaceTerminalTab = Omit< - TerminalTab, - 'worktreeId' | 'pendingActivationSpawn' | 'recovery' -> & { - worktreePath: string -} - -export type RemoteWorkspaceSession = { - activeWorktreePath: string | null - activeTabId: string | null - tabsByWorktreePath: Record - terminalLayoutsByTabId: Record - activeWorktreePathsOnShutdown?: string[] - activeTabIdByWorktreePath?: Record - remoteSessionIdsByTabId?: Record - lastVisitedAtByWorktreePath?: Record - defaultTerminalTabsAppliedByWorktreePath?: Record -} - -export type RemoteWorkspaceSnapshot = { - namespace: string - revision: number - updatedAt: number - schemaVersion: number - session: RemoteWorkspaceSession -} - -export type RemoteWorkspaceObservedSnapshot = RemoteWorkspaceSnapshot & { - hostObservationToken: string -} - -export type RemoteWorkspaceConnectedClient = { - clientId: string - name: string - lastSeenAt: number - isCurrent?: boolean -} - -export type RemoteWorkspacePatchResult = - | { - ok: true - snapshot: RemoteWorkspaceSnapshot - } - | { - ok: false - reason: 'stale-revision' | 'unavailable' - snapshot?: RemoteWorkspaceSnapshot - message?: string - } - -export type RemoteWorkspaceObservedPatchResult = - | { - ok: true - snapshot: RemoteWorkspaceObservedSnapshot - } - | { - ok: false - reason: 'stale-revision' | 'unavailable' - snapshot?: RemoteWorkspaceObservedSnapshot - message?: string - } - -export const REMOTE_WORKSPACE_CHANGED_NOTIFICATION = 'workspace.changed' - -/** - * Sent instead of `workspace.changed` when the snapshot frame did not fit the client's producer - * frame capacity. Carries no session: the client re-reads through `workspace.get`, whose response - * lane is budgeted in megabytes rather than in one ~12KB producer frame. - * - * Wire contract: a relay that predates this never sends it, and a client that predates it drops it - * the same way it drops any unknown notification method — which is exactly the silent drop this - * replaces, so an un-negotiated pairing is never worse than before. - */ -export const REMOTE_WORKSPACE_STALE_NOTIFICATION = 'workspace.stale' - -export type RemoteWorkspaceChangedEvent = { - targetId: string - snapshot: RemoteWorkspaceObservedSnapshot - sourceClientId?: string -} diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index 48d0314a0168..bd2bb3fb38a3 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -53,9 +53,8 @@ export type WorkspaceSessionState = { * (force-park, hibernate, sleep, shutdown) still write it. This field is **local-only**: the * ordinary cold park fires every time a workspace is hidden, and shipping that in a wholesale * `replace-session` costs tens of MiB on the common path for a copy no peer consumes. - * `exportRemoteWorkspaceSession` is an explicit allowlist of named top-level fields, so a - * top-level field is omitted from the upload for free — a field *inside* the layout would ride - * along, because layout entries are copied whole. Do not move it into the layout for tidiness. + * A field *inside* the layout would ride along wherever layout entries are copied whole, so do + * not move it into the layout for tidiness. * * Second property, by construction rather than by a fix: the mirrored-tab apply rewrites only * `ptyIdsByTabId` and `terminalLayoutsByTabId`, so a host inventory frame cannot wipe this. diff --git a/src/shared/workspace-session-stranded-partition-adoption.ts b/src/shared/workspace-session-stranded-partition-adoption.ts index c36d35b4bef4..baf4b2d39c7f 100644 --- a/src/shared/workspace-session-stranded-partition-adoption.ts +++ b/src/shared/workspace-session-stranded-partition-adoption.ts @@ -48,8 +48,7 @@ import { * they had closed on every launch. Leaving it alone keeps this a one-shot repair, at the cost of * not recovering rows stranded beside a populated workspace — which are stranded on main today too, * so it is never a new loss. An EMPTY tab row is not such a copy: an empty list is not evidence - * that anything was closed (`mergeDirectSshRemoteWorkspaceSession` argues this at length, and - * docs/reference/ssh-execution-boundary.md makes it general — "we could not see it" is + * that anything was closed (docs/reference/ssh-execution-boundary.md: "we could not see it" is * `unverifiable`, never proof of absence). Treating it as the truth is what published an empty tab * list and let `replace-session` delete the host's copy (#12721). Nor is a copy of a workspace the * repo catalog places on this host while the host holds its tabs: the base's rows there are residue diff --git a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts b/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts deleted file mode 100644 index 7ac0ec897a7f..000000000000 --- a/tests/e2e/direct-ssh-snapshot-parking.unit.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -// @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' -import { createElement, Fragment, useEffect } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { exportRemoteWorkspaceSession } from '../../src/shared/remote-workspace-session-projection' -import type { - RemoteWorkspaceObservedSnapshot, - RemoteWorkspaceSnapshot -} from '../../src/shared/remote-workspace-types' -import { - resyncStaleRemoteWorkspace, - _resetRemoteWorkspaceStaleResyncForTests -} from '../../src/main/ipc/remote-workspace-stale-resync' -import { - clearRemoteWorkspaceSnapshotCache, - rememberLocallyPatchedRemoteWorkspaceSnapshot, - rememberRemoteWorkspaceSnapshot -} from '../../src/main/ipc/remote-workspace-snapshot-cache' -import type { DirectSshAuthority, SshProviderEpoch, SshTarget } from '../../src/shared/ssh-types' -import { useAppStore } from '@/store' -import { makeTerminalTab, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { applyDirectSshRemoteWorkspaceSnapshot } from '@/hooks/remote-workspace-snapshot-apply' -import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' -import { selectColdParkedTerminalWorktrees } from '@/components/terminal-pane/terminal-hidden-view-parking' -import { - canWatcherCoverParkedTerminalTab, - disposeAllParkedTerminalWatchers -} from '@/components/terminal-pane/terminal-parked-tab-watchers' -import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking' - -const { request } = vi.hoisted(() => ({ request: vi.fn() })) - -vi.mock('../../src/main/ipc/ssh', () => ({ - getActiveMultiplexer: () => ({ request }) -})) - -vi.mock('@/components/terminal-pane/parked-terminal-byte-watcher', () => ({ - startParkedTerminalByteWatcher: () => () => {} -})) - -const START_MS = 1_000_000 -const TARGET_ID = 'ssh-parking-host' -const TARGET: SshTarget = { - id: TARGET_ID, - label: 'SSH', - host: 'example.test', - port: 22, - username: 'dev' -} -const ptyIdFor = (tabId: string): string => `ssh:${TARGET_ID}@@${tabId}` -const WORKTREE_ID = 'repo-ssh::/work/ssh-parking' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const TABS = ['tab-a', 'tab-b'].map((id) => - makeTerminalTab({ id, worktreeId: WORKTREE_ID, ptyId: ptyIdFor(id) }) -) -const ASSIGNMENTS = new Map() -const PORTALS: never[] = [] -const mounts = new Map() -const authority: DirectSshAuthority = { - targetId: TARGET_ID, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: main-issued opaque tokens are stable strings; this fixture models one unchanged provider incarnation. - providerEpoch: 'parking-provider' as SshProviderEpoch, - connectionGeneration: 1 -} -let parkedTabIds: ReadonlySet = new Set() - -function AttachedPane({ tabId }: { tabId: string }): null { - useEffect(() => { - mounts.set(tabId, (mounts.get(tabId) ?? 0) + 1) - // Model transport attachment completing through the real store action. - const timer = setTimeout(() => { - useAppStore.getState().updateTabPtyId(tabId, ptyIdFor(tabId)) - }, 1_100) - return () => clearTimeout(timer) - }, [tabId]) - return null -} - -function ParkingHost(): React.JSX.Element { - const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) - const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) - const terminalTabs = tabsByWorktree[WORKTREE_ID] - const parkedWorktrees = selectColdParkedTerminalWorktrees({ - worktrees: [ - { - worktreeId: WORKTREE_ID, - terminalTabs, - hiddenSinceMs: START_MS - 6 * 60_000, - isVisible: false, - shouldMeasureHiddenWorktree: false, - hasActivityTerminalPortal: false - }, - { - worktreeId: 'retained-peer', - terminalTabs: [], - hiddenSinceMs: START_MS - 5 * 60_000, - isVisible: false, - shouldMeasureHiddenWorktree: false, - hasActivityTerminalPortal: false - } - ], - pendingStartupByTabId, - parkingEnabled: true, - restorePolicy: { sshParkingEnabled: true }, - nowMs: START_MS - }) - parkedTabIds = useTerminalTabColdParking({ - worktreeId: WORKTREE_ID, - terminalTabs, - assignments: ASSIGNMENTS, - isWorktreeActive: false, - activeTerminalTabId: null, - coldParkTerminalPanes: - parkedWorktrees.has(WORKTREE_ID) && - terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(WORKTREE_ID, tab)), - shouldMeasureHiddenWorktree: false, - activityTerminalPortals: PORTALS - }) - return createElement( - Fragment, - null, - terminalTabs.map((tab) => - parkedTabIds.has(tab.id) ? null : createElement(AttachedPane, { key: tab.id, tabId: tab.id }) - ) - ) -} - -function ownSnapshot(revision: number): RemoteWorkspaceSnapshot { - return { - namespace: 'workspace', - revision, - updatedAt: Date.now(), - schemaVersion: 1, - session: exportRemoteWorkspaceSession(buildWorkspaceSessionPayload(useAppStore.getState()), { - isTargetWorktree: (id) => id === WORKTREE_ID - }) - } -} - -async function applySnapshot(snapshot: RemoteWorkspaceObservedSnapshot): Promise { - const result = await applyDirectSshRemoteWorkspaceSnapshot({ - store: useAppStore, - snapshot, - token: { - authority, - catalogRevision: 0, - repoFingerprint: 'parking-repo', - authorityRequirement: 'required', - snapshotRevision: snapshot.revision, - outcome: 'complete' - }, - arrival: snapshot.revision, - isArrivalCurrent: () => true, - isPreparationTokenCurrent: () => true, - waitForWorkspaceSessionReady: async () => true, - finalizeHydratedTerminals: () => useAppStore.getState().retryDirectSshTargetPanes(authority) - }) - expect(result).toBe('applied') -} - -async function deliverReadAfterOwnReply(own: RemoteWorkspaceSnapshot, read = own): Promise { - request.mockResolvedValue(read) - let releaseRead: ((snapshot: RemoteWorkspaceSnapshot) => void) | undefined - request.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRead = resolve - }) - ) - const applies: Promise[] = [] - const errors: unknown[] = [] - const resync = resyncStaleRemoteWorkspace( - TARGET, - (snapshot) => { - applies.push(applySnapshot(snapshot)) - }, - (error) => errors.push(error) - ) - expect(request).toHaveBeenLastCalledWith('workspace.get', { namespace: expect.any(String) }) - rememberLocallyPatchedRemoteWorkspaceSnapshot(TARGET_ID, own) - await act(async () => { - releaseRead?.(read) - await resync - await Promise.all(applies) - }) - expect(errors).toEqual([]) -} - -describe('direct SSH snapshot delivery and parking', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(START_MS) - mounts.clear() - request.mockReset() - clearRemoteWorkspaceSnapshotCache() - _resetRemoteWorkspaceStaleResyncForTests() - Object.defineProperty(window, 'api', { - configurable: true, - value: { - pty: { - onData: () => () => {}, - onReplay: () => () => {}, - onExit: () => () => {} - } - } - }) - disposeAllParkedTerminalWatchers() - useAppStore.setState({ - ...useAppStore.getInitialState(), - workspaceSessionReady: true, - hydrationSucceeded: true, - repos: [ - { - id: 'repo-ssh', - path: '/work', - displayName: 'SSH', - badgeColor: 'blue', - addedAt: 0, - connectionId: TARGET_ID - } - ], - worktreesByRepo: { - 'repo-ssh': [ - makeWorktree({ - id: WORKTREE_ID, - repoId: 'repo-ssh', - path: '/work/ssh-parking', - hostId: `ssh:${TARGET_ID}` - }) - ] - }, - sshConnectionStates: new Map([ - [TARGET_ID, { ...authority, status: 'connected', error: null, reconnectAttempt: 0 }] - ]), - tabsByWorktree: { [WORKTREE_ID]: TABS }, - ptyIdsByTabId: Object.fromEntries(TABS.map((tab) => [tab.id, [ptyIdFor(tab.id)]])), - lastKnownRelayPtyIdByTabId: Object.fromEntries(TABS.map((tab) => [tab.id, ptyIdFor(tab.id)])), - terminalLayoutsByTabId: Object.fromEntries( - TABS.map((tab) => [tab.id, singlePaneLayoutSnapshot(LEAF_ID, ptyIdFor(tab.id))]) - ) - }) - }) - - afterEach(() => { - cleanup() - disposeAllParkedTerminalWatchers() - useAppStore.setState(useAppStore.getInitialState(), true) - vi.useRealTimers() - }) - - it('keeps panes parked through pending-read own echoes while still applying a peer edit', async () => { - render(createElement(ParkingHost)) - expect(parkedTabIds).toEqual(new Set(TABS.map((tab) => tab.id))) - expect(mounts.size).toBe(0) - rememberRemoteWorkspaceSnapshot(TARGET_ID, ownSnapshot(0)) - - // Visibility, ordering, measurement and saved PTY coverage stay unchanged throughout. - - for (let revision = 1; revision <= 4; revision += 1) { - act(() => { - vi.advanceTimersByTime(2_000) - useAppStore.getState().updateTabTitle('tab-a', `Local title ${revision}`) - }) - await deliverReadAfterOwnReply(ownSnapshot(revision)) - expect(parkedTabIds).toEqual(new Set(TABS.map((tab) => tab.id))) - expect(mounts.size).toBe(0) - } - - const own = ownSnapshot(5) - const peer = structuredClone(own) - peer.revision = 6 - for (const tabs of Object.values(peer.session.tabsByWorktreePath)) { - for (const tab of tabs) { - if (tab.id === 'tab-a') { - tab.title = 'Peer title' - } - } - } - await deliverReadAfterOwnReply(own, peer) - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID][0].title).toBe('Peer title') - expect([...mounts.values()]).toEqual([1, 1]) - expect(parkedTabIds.size).toBe(0) - act(() => vi.advanceTimersByTime(1_100)) - expect(parkedTabIds).toEqual(new Set(TABS.map((tab) => tab.id))) - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID].map((tab) => tab.ptyId)).toEqual( - TABS.map((tab) => ptyIdFor(tab.id)) - ) - }) -}) From 6b747f8c2d8f07aa04cb3ee1700f2ed3122867e7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 01:10:44 -0700 Subject: [PATCH 24/58] test(e2e): delete helpers orphaned by relay deletion (#27411) Co-authored-by: m4air --- config/reliability-gates.jsonc | 25 +-- config/scripts/ci-shard-timings.json | 4 - .../helpers/docker-ssh-relay-connection.ts | 133 --------------- .../helpers/docker-ssh-relay-terminal-tabs.ts | 38 ----- .../nested-runtime-proxy-jump-fixture.ts | 34 ---- ...ed-runtime-proxy-jump-fixture.unit.test.ts | 26 --- .../e2e/helpers/remote-typing-load-script.ts | 22 --- .../remote-typing-load-script.unit.test.ts | 69 -------- tests/e2e/helpers/reserve-local-port.ts | 32 ++++ .../ssh-port-forward-snapshot-barrier.ts | 158 ------------------ ...port-forward-snapshot-barrier.unit.test.ts | 69 -------- tests/e2e/ssh-codex-replay-reply-probe.ts | 55 ------ .../ssh-codex-replay-reply-probe.unit.test.ts | 52 ------ tests/e2e/ssh-orcad-workspace-tools.spec.ts | 2 +- 14 files changed, 38 insertions(+), 681 deletions(-) delete mode 100644 tests/e2e/helpers/docker-ssh-relay-connection.ts delete mode 100644 tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts delete mode 100644 tests/e2e/helpers/nested-runtime-proxy-jump-fixture.ts delete mode 100644 tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts delete mode 100644 tests/e2e/helpers/remote-typing-load-script.ts delete mode 100644 tests/e2e/helpers/remote-typing-load-script.unit.test.ts create mode 100644 tests/e2e/helpers/reserve-local-port.ts delete mode 100644 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts delete mode 100644 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts delete mode 100644 tests/e2e/ssh-codex-replay-reply-probe.ts delete mode 100644 tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 6ec8d482367d..e3a5b1c3e3ba 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -18870,15 +18870,14 @@ "invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.", "oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e" ], "testFiles": [ "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "src/main/ssh/ssh-port-forward.test.ts", "src/main/ssh/system-ssh-forward-process.test.ts", - "src/main/ipc/ssh.test.ts", - "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts" + "src/main/ipc/ssh.test.ts" ], "assertionRefs": [ { @@ -18903,10 +18902,6 @@ "preserves active port forwards and live connections across handler re-registration", "persists desired forwards and broadcasts when an active forward closes unexpectedly" ] - }, - { - "file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", - "assertions": ["holds only the first matching request while its snapshot is unresolved"] } ], "evidenceRuns": [ @@ -18914,7 +18909,7 @@ "date": "2026-07-30", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts --reporter=dot", "result": "passed", "durationSeconds": 6.77, "summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts." @@ -20148,13 +20143,9 @@ "invariant": "Transport loss and frozen-host silence must preserve the remote session; host relay loss may rebind a pane without accumulating reattachable leases. Reconnects must preserve usable terminal content, bounded PTYs/fds/processes, complete large listings, and independently recoverable watcher processes. Electron test shutdown must release inherited pipes after confirmed root exit without closing live-process pipes.", "oracle": "Poll a changed connected SSH authority after injected faults, then require terminal output and appropriate PTY identity. Read remote process/fd state, listFiles replies, and rendered explorer rows. Resolve Playwright cleanup only after the root process exits and its inherited pipes close; live-process pipes remain untouched.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts" - ], - "testFiles": [ - "tests/e2e/helpers/electron-process-shutdown.unit.test.ts", - "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts" ], + "testFiles": ["tests/e2e/helpers/electron-process-shutdown.unit.test.ts"], "assertionRefs": [ { "file": "tests/e2e/helpers/electron-process-shutdown.unit.test.ts", @@ -20162,12 +20153,6 @@ "releases inherited pipes after confirmed exit, including prior exit", "retains live-process pipes on shutdown timeout" ] - }, - { - "file": "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts", - "assertions": [ - "unrelated, replacement, initial-spawn, empty and non-replay replies do not count; original reattach results and failures pass through unchanged" - ] } ], "evidenceRuns": [ diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index d583f8e553f0..38e19825aed6 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -11763,14 +11763,11 @@ "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 59, "tests/e2e/helpers/golden-source-control.unit.test.ts": 645, "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 56, - "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 45, "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 59, "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 179, "tests/e2e/helpers/persisted-profile-state.unit.test.ts": 470, "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 421, "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 65, - "tests/e2e/helpers/remote-typing-load-script.unit.test.ts": 276, - "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 117, "tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 51, "tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts": 64, "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 7476, @@ -11792,7 +11789,6 @@ "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 1142, "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 5281, "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts": 362, - "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 64, "tests/e2e/structured-agent-session-read-owner.unit.test.ts": 2988, "tests/e2e/structured-attention-explicit-read.unit.test.ts": 13645, "tests/e2e/structured-attention-headless-delivery.unit.test.ts": 259, diff --git a/tests/e2e/helpers/docker-ssh-relay-connection.ts b/tests/e2e/helpers/docker-ssh-relay-connection.ts deleted file mode 100644 index caf29d40ce5a..000000000000 --- a/tests/e2e/helpers/docker-ssh-relay-connection.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { connectSshTestTarget } from './ssh-test-target-connection' -import { expect, type Page } from '@stablyai/playwright-test' - -import { - DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH, - DOCKER_SSH_RELAY_REMOTE_REPO_PATH, - type DockerSshRelayTarget -} from './docker-ssh-relay-target' - -export type ConnectedDockerSshRelayTarget = { - targetId: string - repoId: string - worktreeId: string -} - -type DockerSshRelayConnectionOptions = { - relayGracePeriodSeconds?: number - remotePath?: string - viaProxyJump?: boolean - /** - * Seed a terminal tab when the worktree has none. Default true. - * - * Why it is optional: a spec asking whether the PRODUCT adds a tab cannot tell this helper's - * tab from the one under test, so it must be able to leave the worktree empty. - */ - seedInitialTab?: boolean -} - -export async function connectDockerSshRelayTarget( - page: Page, - target: DockerSshRelayTarget, - options: DockerSshRelayConnectionOptions = {} -): Promise { - const viaProxyJump = options.viaProxyJump ?? false - return connectSshTestTarget( - page, - { - label: `${viaProxyJump ? 'Docker SSH ProxyJump' : 'Docker SSH Relay'} E2E ${Date.now()}`, - ...(viaProxyJump ? { configHost: 'orca-e2e-destination' } : {}), - host: target.host, - port: viaProxyJump ? 22 : target.port, - username: 'root', - identityFile: target.identityFile, - identitiesOnly: true, - ...(viaProxyJump ? { jumpHost: 'orca-e2e-jump' } : {}), - relayGracePeriodSeconds: options.relayGracePeriodSeconds ?? 1 - }, - { - remotePath: - options.remotePath ?? - (viaProxyJump ? DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH : DOCKER_SSH_RELAY_REMOTE_REPO_PATH), - displayName: viaProxyJump ? 'Docker SSH ProxyJump E2E' : 'Docker SSH Relay E2E', - seedInitialTab: options.seedInitialTab - } - ) -} - -export async function disconnectDockerSshRelayTarget(page: Page, targetId: string): Promise { - await page.evaluate(async (targetId) => { - await window.api.ssh.disconnect({ targetId }) - }, targetId) -} - -export async function resetDockerSshRelayTarget(page: Page, targetId: string): Promise { - await page.evaluate(async (targetId) => { - await window.api.ssh.resetRelay({ targetId }) - }, targetId) -} - -async function performDockerSshRelayReconnect( - page: Page, - targetId: string, - disconnectFirst: boolean -): Promise { - await page.evaluate( - async ({ targetId, disconnectFirst }) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - if (disconnectFirst) { - await window.api.ssh.disconnect({ targetId }) - } - const state = await window.api.ssh.connect({ targetId }) - if (!state || state.status !== 'connected') { - throw new Error(`SSH target did not reconnect: ${JSON.stringify(state)}`) - } - store.getState().setSshConnectionState(targetId, state) - }, - { targetId, disconnectFirst } - ) -} - -export async function reconnectDockerSshRelayTarget(page: Page, targetId: string): Promise { - return performDockerSshRelayReconnect(page, targetId, true) -} - -export async function reconnectDisconnectedDockerSshRelayTarget( - page: Page, - targetId: string -): Promise { - return performDockerSshRelayReconnect(page, targetId, false) -} - -export async function recoverDockerSshRelayAfterFault( - page: Page, - targetId: string, - injectFault: () => void | Promise -): Promise { - const readAuthority = () => - page.evaluate((id) => window.__store?.getState().sshConnectionStates.get(id), targetId) - const before = await readAuthority() - expect(before).toMatchObject({ - status: 'connected', - providerEpoch: expect.any(String), - connectionGeneration: expect.any(Number) - }) - await injectFault() - // The pre-fault connected publication can remain visible until the next IPC event. - await expect - .poll( - async () => { - const after = await readAuthority() - return ( - after?.status === 'connected' && - (after.providerEpoch !== before?.providerEpoch || - after.connectionGeneration !== before?.connectionGeneration) - ) - }, - { timeout: 120_000, message: 'SSH authority did not recover after the injected fault' } - ) - .toBe(true) -} diff --git a/tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts b/tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts deleted file mode 100644 index b1ab4a4e2789..000000000000 --- a/tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, type Page } from '@stablyai/playwright-test' - -import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal' - -/** Add one more terminal tab to a connected remote worktree and wait for it to own a PTY. */ -export async function createRemoteTerminalTab(page: Page, worktreeId: string): Promise { - const tabId = await page.evaluate((id) => { - const state = window.__store?.getState() - if (!state) { - throw new Error('Store unavailable') - } - const tab = state.createTab(id, undefined, undefined, { activate: true }) - state.setActiveTab(tab.id) - state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null) - return tab.id - }, worktreeId) - await expect - .poll(() => page.evaluate(() => window.__store?.getState().activeTabId ?? null), { - timeout: 10_000 - }) - .toBe(tabId) - await waitForActiveTerminalManager(page, 60_000) - await waitForActivePanePtyId(page, 60_000) -} - -export async function readRemoteTerminalTabs( - page: Page, - worktreeId: string -): Promise<{ id: string; ptyId: string | null }[]> { - return page.evaluate( - (id) => - (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => ({ - id: tab.id, - ptyId: tab.ptyId - })), - worktreeId - ) -} diff --git a/tests/e2e/helpers/nested-runtime-proxy-jump-fixture.ts b/tests/e2e/helpers/nested-runtime-proxy-jump-fixture.ts deleted file mode 100644 index a8202292a5f6..000000000000 --- a/tests/e2e/helpers/nested-runtime-proxy-jump-fixture.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -export type NestedRuntimeProxyJumpFixture = { - configPath: string - directory: string - wrapperPath: string - dispose(): void - writeConfig(contents: string): void -} - -export function createNestedRuntimeProxyJumpFixture(): NestedRuntimeProxyJumpFixture { - const directory = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-proxy-jump-')) - const configPath = path.join(directory, 'ssh-config') - const wrapperPath = path.join(directory, 'ssh') - try { - // Why: OpenSSH ignores an overridden HOME on macOS, so force the disposable HUB-only config explicitly. - writeFileSync(wrapperPath, `#!/bin/sh\nexec /usr/bin/ssh -F "${configPath}" "$@"\n`, { - mode: 0o700 - }) - chmodSync(wrapperPath, 0o700) - } catch (error) { - rmSync(directory, { force: true, recursive: true }) - throw error - } - return { - configPath, - directory, - wrapperPath, - dispose: () => rmSync(directory, { force: true, recursive: true }), - writeConfig: (contents) => writeFileSync(configPath, contents, { mode: 0o600 }) - } -} diff --git a/tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts b/tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts deleted file mode 100644 index 3e80da711d41..000000000000 --- a/tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { afterEach, describe, expect, it } from 'vitest' -import { - createNestedRuntimeProxyJumpFixture, - type NestedRuntimeProxyJumpFixture -} from './nested-runtime-proxy-jump-fixture' - -describe('nested runtime ProxyJump fixture', () => { - let fixture: NestedRuntimeProxyJumpFixture | null = null - - afterEach(() => fixture?.dispose()) - - it('removes its exact wrapper and config directory on disposal', () => { - fixture = createNestedRuntimeProxyJumpFixture() - fixture.writeConfig('Host destination\n HostName 127.0.0.1\n') - - expect(statSync(fixture.wrapperPath).mode & 0o111).not.toBe(0) - expect(readFileSync(fixture.configPath, 'utf8')).toContain('Host destination') - - const directory = fixture.directory - fixture.dispose() - fixture = null - - expect(existsSync(directory)).toBe(false) - }) -}) diff --git a/tests/e2e/helpers/remote-typing-load-script.ts b/tests/e2e/helpers/remote-typing-load-script.ts deleted file mode 100644 index 8eba98454abc..000000000000 --- a/tests/e2e/helpers/remote-typing-load-script.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function remoteTypingLoadScript(runId: string): string { - return [ - "process.stdin.setEncoding('utf8')", - 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', - 'process.stdin.resume()', - 'const statusRow = Math.max(2, process.stdout.rows || 24)', - "process.stdout.write('\\x1b[1;' + (statusRow - 1) + 'r')", - 'let seq = 0', - 'let frame = 0', - 'let bg = null', - `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, - "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('\\x1b[1;1HBG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", - "process.stdin.on('data', (chunk) => {", - ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', - ' for (const char of chunk) {', - " if (char === '\\r' || char === '\\n') continue", - ' seq += 1', - ` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KKEY_${runId}_' + seq + '_' + char)`, - ' }', - '})' - ].join(';') -} diff --git a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts deleted file mode 100644 index ae9c55cd847c..000000000000 --- a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import '../../../src/main/daemon/xterm-env-polyfill' -import { EventEmitter } from 'node:events' -import { runInNewContext } from 'node:vm' -import { Terminal } from '@xterm/headless' -import { SerializeAddon } from '@xterm/addon-serialize' -import { expect, it } from 'vitest' -import { remoteTypingLoadScript } from './remote-typing-load-script' - -it.each([ - { rows: 12, cols: 80 }, - { rows: 24, cols: 80 }, - { rows: 40, cols: 80 }, - { rows: 12, cols: 35 }, - { rows: 24, cols: 35 }, - { rows: 40, cols: 35 } -])( - 'keeps typed output visible through background pressure at $rows rows and $cols columns', - async ({ rows, cols }) => { - const terminal = new Terminal({ rows, cols, scrollback: 1000, allowProposedApi: true }) - const serializer = new SerializeAddon() - terminal.loadAddon(serializer) - const runId = cols === 80 ? 'test' : '1790930671681_active' - const input = new EventEmitter() - const chunks: string[] = [] - let background = (): void => { - throw new Error('Background pressure did not start') - } - try { - runInNewContext(remoteTypingLoadScript(runId), { - process: { - stdin: { - isTTY: true, - setEncoding() {}, - setRawMode() {}, - resume() {}, - on: input.on.bind(input) - }, - stdout: { rows, cols, write: (chunk: string) => chunks.push(chunk) }, - exit() {} - }, - setTimeout: (callback: () => void) => callback(), - setInterval: (callback: () => void) => { - background = callback - return 1 - }, - clearInterval() {} - }) - input.emit('data', cols === 80 ? 'ab\r\ncd' : 'abcdefghij\r\n') - for (let frame = 0; frame < 40; frame += 1) { - background() - } - const output = chunks.join('') - expect(output.length).toBeGreaterThan(160_000) - await new Promise((resolve) => terminal.write(output, resolve)) - const screen = Array.from( - { length: rows }, - (_, row) => - terminal.buffer.active - .getLine(terminal.buffer.active.baseY + row) - ?.translateToString(true) ?? '' - ).join('\n') - const reply = cols === 80 ? 'KEY_test_4_d' : `KEY_${runId}_10_j` - expect(screen).toContain(reply) - expect(serializer.serialize()).toContain(reply) - } finally { - terminal.dispose() - } - } -) diff --git a/tests/e2e/helpers/reserve-local-port.ts b/tests/e2e/helpers/reserve-local-port.ts new file mode 100644 index 000000000000..02154b496908 --- /dev/null +++ b/tests/e2e/helpers/reserve-local-port.ts @@ -0,0 +1,32 @@ +import { createServer } from 'node:net' + +export type ReservedLocalPort = { + port: number + release: () => Promise +} + +export async function reserveLocalPort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + throw new Error('Unable to reserve a local port') + } + let released = false + return { + port: address.port, + release: async () => { + if (released) { + return + } + released = true + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } + } +} diff --git a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts deleted file mode 100644 index 7127a754163d..000000000000 --- a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createServer } from 'node:net' - -import type { ElectronApplication } from '@stablyai/playwright-test' - -type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown - -type SnapshotBarrierState = { - targetId: string - captureClaimed: boolean - captured: boolean - released: boolean - release: () => void - originalHandler: InvokeHandler - handlerReturned: Promise - markHandlerReturned: () => void -} - -export type ReservedLocalPort = { - port: number - release: () => Promise -} - -export async function reserveLocalPort(): Promise { - const server = createServer() - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (!address || typeof address === 'string') { - server.close() - throw new Error('Unable to reserve a local port') - } - let released = false - return { - port: address.port, - release: async () => { - if (released) { - return - } - released = true - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ) - } - } -} - -export async function installSshPortForwardSnapshotBarrier( - app: ElectronApplication, - targetId: string -): Promise { - await app.evaluate(({ ipcMain }, targetId) => { - const scope = globalThis as typeof globalThis & { - __sshPortForwardSnapshotBarrier?: SnapshotBarrierState - } - const handlers = ( - ipcMain as unknown as { - _invokeHandlers?: Map - } - )._invokeHandlers - const originalHandler = handlers?.get('ssh:listPortForwards') - if (!handlers || !originalHandler) { - throw new Error('ssh:listPortForwards handler is unavailable') - } - if (scope.__sshPortForwardSnapshotBarrier) { - throw new Error('SSH port-forward snapshot barrier is already installed') - } - let release!: () => void - const barrier = new Promise((resolve) => { - release = resolve - }) - let markHandlerReturned!: () => void - const handlerReturned = new Promise((resolve) => { - markHandlerReturned = resolve - }) - const state: SnapshotBarrierState = { - targetId, - captureClaimed: false, - captured: false, - released: false, - release, - originalHandler, - handlerReturned, - markHandlerReturned - } - scope.__sshPortForwardSnapshotBarrier = state - handlers.set('ssh:listPortForwards', async (event, args) => { - if (state.captureClaimed || args?.targetId !== state.targetId) { - return state.originalHandler(event, args) - } - state.captureClaimed = true - const snapshot = await state.originalHandler(event, args) - state.captured = true - await barrier - state.markHandlerReturned() - return snapshot - }) - }, targetId) -} - -export async function readSshPortForwardSnapshotBarrier( - app: ElectronApplication -): Promise<{ captured: boolean; released: boolean }> { - return app.evaluate(() => { - const state = ( - globalThis as typeof globalThis & { - __sshPortForwardSnapshotBarrier?: SnapshotBarrierState - } - ).__sshPortForwardSnapshotBarrier - return { - captured: state?.captured ?? false, - released: state?.released ?? false - } - }) -} - -export async function releaseSshPortForwardSnapshotBarrier( - app: ElectronApplication -): Promise { - await app.evaluate(async () => { - const state = ( - globalThis as typeof globalThis & { - __sshPortForwardSnapshotBarrier?: SnapshotBarrierState - } - ).__sshPortForwardSnapshotBarrier - if (state && !state.released) { - state.released = true - state.release() - } - await state?.handlerReturned - }) -} - -export async function restoreSshPortForwardSnapshotHandler( - app: ElectronApplication -): Promise { - await app.evaluate(({ ipcMain }) => { - const scope = globalThis as typeof globalThis & { - __sshPortForwardSnapshotBarrier?: SnapshotBarrierState - } - const state = scope.__sshPortForwardSnapshotBarrier - if (!state) { - return - } - if (!state.released) { - state.released = true - state.release() - } - const handlers = ( - ipcMain as unknown as { - _invokeHandlers?: Map - } - )._invokeHandlers - handlers?.set('ssh:listPortForwards', state.originalHandler) - delete scope.__sshPortForwardSnapshotBarrier - }) -} diff --git a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts deleted file mode 100644 index e85564d185e2..000000000000 --- a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' -import { describe, expect, it, vi } from 'vitest' -import { - installSshPortForwardSnapshotBarrier, - readSshPortForwardSnapshotBarrier, - releaseSshPortForwardSnapshotBarrier, - restoreSshPortForwardSnapshotHandler -} from './ssh-port-forward-snapshot-barrier' - -type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown - -describe('SSH port-forward snapshot barrier', () => { - it('holds only the first matching request while its snapshot is unresolved', async () => { - const handlers = new Map() - let resolveFirstSnapshot: (value: string[]) => void = () => {} - const firstSnapshot = new Promise((resolve) => { - resolveFirstSnapshot = resolve - }) - let callCount = 0 - const originalHandler = vi.fn(() => { - callCount += 1 - return callCount === 1 ? firstSnapshot : Promise.resolve(['later-snapshot']) - }) - handlers.set('ssh:listPortForwards', originalHandler) - const app = { - evaluate: ( - callback: (electron: unknown, arg?: unknown) => unknown, - arg?: unknown - ): Promise => - Promise.resolve(callback({ ipcMain: { _invokeHandlers: handlers } }, arg)) - } as unknown as ElectronApplication - - let heldRequestStarted = false - await installSshPortForwardSnapshotBarrier(app, 'target-1') - try { - const wrappedHandler = handlers.get('ssh:listPortForwards') - expect(wrappedHandler).toBeTypeOf('function') - if (!wrappedHandler) { - throw new Error('Wrapped handler unavailable') - } - - heldRequestStarted = true - const firstRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' })) - await vi.waitFor(() => expect(originalHandler).toHaveBeenCalledOnce()) - const laterRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' })) - await expect(laterRequest).resolves.toEqual(['later-snapshot']) - expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({ - captured: false, - released: false - }) - - resolveFirstSnapshot(['held-snapshot']) - await vi.waitFor(async () => { - expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({ - captured: true, - released: false - }) - }) - await releaseSshPortForwardSnapshotBarrier(app) - await expect(firstRequest).resolves.toEqual(['held-snapshot']) - } finally { - resolveFirstSnapshot(['cleanup-snapshot']) - if (heldRequestStarted) { - await releaseSshPortForwardSnapshotBarrier(app) - } - await restoreSshPortForwardSnapshotHandler(app) - } - }) -}) diff --git a/tests/e2e/ssh-codex-replay-reply-probe.ts b/tests/e2e/ssh-codex-replay-reply-probe.ts deleted file mode 100644 index 27981628314e..000000000000 --- a/tests/e2e/ssh-codex-replay-reply-probe.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' - -type ReplayPayload = { id: string; length: number; preview: string; source: 'spawn-reply' } -type SpawnHandler = (event: unknown, args: Record) => Promise -type ReplayReplyScope = typeof globalThis & { - __orcaSshCodexReplayReplies?: ReplayPayload[] -} - -export async function installSshReplayReplyProbe( - app: ElectronApplication, - ptyId: string -): Promise { - await app.evaluate(({ ipcMain }, expectedPtyId) => { - const scope = globalThis as ReplayReplyScope - if (scope.__orcaSshCodexReplayReplies) { - throw new Error('SSH replay reply probe already installed') - } - const handlers = (ipcMain as unknown as { _invokeHandlers?: Map }) - ._invokeHandlers - const original = handlers?.get('pty:spawn') - if (!handlers || !original) { - throw new Error('PTY spawn handler unavailable') - } - const payloads: ReplayPayload[] = [] - scope.__orcaSshCodexReplayReplies = payloads - // SSH reconnect returns its replay with the reattach reply, without a pty:replay push. - handlers.set('pty:spawn', async (event, args) => { - const result = await original(event, args) - if ( - args.sessionId === expectedPtyId && - result && - typeof result === 'object' && - 'id' in result && - result.id === expectedPtyId && - 'isReattach' in result && - result.isReattach === true && - 'replay' in result && - typeof result.replay === 'string' && - result.replay.length > 0 - ) { - payloads.push({ - id: expectedPtyId, - length: result.replay.length, - preview: result.replay.slice(-400), - source: 'spawn-reply' - }) - } - return result - }) - }, ptyId) -} - -export async function readSshReplayReplies(app: ElectronApplication): Promise { - return app.evaluate(() => (globalThis as ReplayReplyScope).__orcaSshCodexReplayReplies ?? []) -} diff --git a/tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts b/tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts deleted file mode 100644 index a52f07941b76..000000000000 --- a/tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ElectronApplication } from '@stablyai/playwright-test' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { installSshReplayReplyProbe, readSshReplayReplies } from './ssh-codex-replay-reply-probe' - -beforeEach(() => vi.stubGlobal('__orcaSshCodexReplayReplies', undefined)) -afterEach(() => vi.unstubAllGlobals()) - -function harness(result: unknown) { - const original = vi.fn().mockResolvedValue(result) - const handlers = new Map([['pty:spawn', original]]) - const app = { - evaluate: (fn: (electron: unknown, arg: unknown) => unknown, arg: unknown) => - fn({ ipcMain: { _invokeHandlers: handlers } }, arg) - } as unknown as ElectronApplication - return { app, original, handlers } -} - -it('records the original PTY reattach reply without changing the handler result', async () => { - const result = { id: 'ssh:target@@pty-1', isReattach: true, replay: 'restored output' } - const { app, handlers, original } = harness(result) - await installSshReplayReplyProbe(app, result.id) - const event = {} - const args = { sessionId: result.id } - expect(await handlers.get('pty:spawn')!(event, args)).toBe(result) - expect(original).toHaveBeenCalledWith(event, args) - expect(await readSshReplayReplies(app)).toEqual([ - { id: result.id, length: 15, preview: 'restored output', source: 'spawn-reply' } - ]) -}) - -it.each([ - [{}, { id: 'wanted', isReattach: true, replay: 'initial' }], - [{ sessionId: 'other' }, { id: 'other', isReattach: true, replay: 'other PTY' }], - [{ sessionId: 'wanted' }, { id: 'replacement', isReattach: true, replay: 'new PTY' }], - [{ sessionId: 'wanted' }, { id: 'wanted', replay: 'no reattach proof' }], - [{ sessionId: 'wanted' }, { id: 'wanted', isReattach: true, replay: '' }], - [{ sessionId: 'wanted' }, { id: 'wanted', isReattach: true, snapshot: 'not replay' }] -])('does not count unrelated or unproven replay: %j', async (args, result) => { - const { app, handlers } = harness(result) - await installSshReplayReplyProbe(app, 'wanted') - expect(await handlers.get('pty:spawn')!({}, args)).toBe(result) - expect(await readSshReplayReplies(app)).toEqual([]) -}) - -it('preserves a failed reattach without recording replay', async () => { - const { app, handlers, original } = harness(null) - const error = new Error('unverifiable') - original.mockRejectedValue(error) - await installSshReplayReplyProbe(app, 'wanted') - await expect(handlers.get('pty:spawn')!({}, { sessionId: 'wanted' })).rejects.toBe(error) - expect(await readSshReplayReplies(app)).toEqual([]) -}) diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index 8f9c6eddc808..7725e1fead8d 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -7,7 +7,7 @@ import { expect, test } from './helpers/orca-app' import { reconnect } from './helpers/orcad-convert-flow' import { shellQuote } from './helpers/docker-ssh-relay-target' import { callEnvironment, createPairedHostTerminal } from './helpers/paired-host-terminal' -import { reserveLocalPort } from './helpers/ssh-port-forward-snapshot-barrier' +import { reserveLocalPort } from './helpers/reserve-local-port' import { openTerminalWorkspaceRootLink } from './helpers/terminal-workspace-root-link' import { echoMarker, From 57452c681f27c03fd3c016a06da6315777db775a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:12:48 -0700 Subject: [PATCH 25/58] Fix cold proxy authentication in the desktop HTTP client (#26957) * fix(network): answer authenticated proxy challenges on cold desktop requests * fix(network): destroy a stalled proxy-fetch body on cancel --------- Co-authored-by: m4air --- config/scripts/vitest-node-runtime-files.mjs | 2 + src/main/host/electron-http-client.test.ts | 189 ++++++++++++++++++ src/main/host/electron-http-client.ts | 40 +++- src/main/host/electron-proxy-login-fetch.ts | 116 +++++++++++ .../network/electron-proxy-credentials.ts | 24 ++- 5 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 src/main/host/electron-http-client.test.ts create mode 100644 src/main/host/electron-proxy-login-fetch.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 77a972bb934b..be25f78cc489 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -12,6 +12,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'tests/e2e/helpers/electron-process-shutdown.unit.test.ts', 'src/main/ai-vault-search/session-search-index-writer.test.ts', 'src/main/ai-vault/session-scanner-unlimited-dedup.test.ts', + // Electron main bridges streams with Node's implementation. + 'src/main/host/electron-http-client.test.ts', 'src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts', 'src/renderer/src/lib/react-commit-cascade-observer.test.ts', 'src/renderer/src/lib/react-commit-cascade-telemetry.test.ts', diff --git a/src/main/host/electron-http-client.test.ts b/src/main/host/electron-http-client.test.ts new file mode 100644 index 000000000000..8bd405bbd66e --- /dev/null +++ b/src/main/host/electron-http-client.test.ts @@ -0,0 +1,189 @@ +import { Readable, Writable } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type LoginCallback = (username?: string, password?: string) => void +type ProxyChallenge = { isProxy: boolean; host: string; port: number } + +class FakeClientRequest extends Writable { + readonly body: Buffer[] = [] + readonly headers = new Map() + readonly abort = vi.fn() + ended = false + + override _write(chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void { + this.body.push(chunk) + callback() + } + + override _final(callback: () => void): void { + this.ended = true + callback() + } + + setHeader(key: string, value: string): void { + this.headers.set(key, value) + } + + challenge(authInfo: ProxyChallenge): LoginCallback { + const callback = vi.fn() + this.emit('login', authInfo, callback) + return callback + } + + respond(status: number, text: string | null): Readable { + // null leaves the body stalled after headers. + const stream = text === null ? new Readable({ read() {} }) : Readable.from([Buffer.from(text)]) + const response = Object.assign(stream, { + statusCode: status, + statusMessage: 'OK', + headers: { 'content-type': 'text/plain', 'set-cookie': ['a=1', 'b=2'] } + }) + this.emit('response', response) + return response + } +} + +const mocks = vi.hoisted(() => ({ + defaultSession: { + resolveProxy: async (): Promise => 'DIRECT', + setProxy: async (): Promise => {} + }, + netFetch: vi.fn(), + netRequest: vi.fn(), + readiness: vi.fn<() => boolean | Promise>(() => true) +})) + +vi.mock('electron', () => ({ + net: { fetch: mocks.netFetch, request: mocks.netRequest }, + session: { defaultSession: mocks.defaultSession } +})) + +vi.mock('../network/proxy-settings', () => ({ + getProxySessionApplicationReadiness: mocks.readiness +})) + +import { + resetElectronProxyCredentialsForTests, + setElectronProxyCredentialsForSession +} from '../network/electron-proxy-credentials' +import { electronHttpClient } from './electron-http-client' + +const PROXY = { host: 'proxy.corp', port: 8080, username: 'user', password: 'secret' } +let native: FakeClientRequest + +// Wrapped so awaiting the request start does not also await its response. +async function startRequest(init?: RequestInit): Promise<{ pending: Promise }> { + const pending = electronHttpClient.fetch('https://api.example.com/v1', init) + await vi.waitFor(() => expect(mocks.netRequest).toHaveBeenCalled()) + return { pending } +} + +beforeEach(() => { + native = new FakeClientRequest() + mocks.netRequest.mockReturnValue(native) + mocks.netFetch.mockResolvedValue(new Response('direct')) + mocks.readiness.mockReturnValue(true) +}) + +afterEach(() => { + vi.clearAllMocks() + resetElectronProxyCredentialsForTests() +}) + +describe('electronHttpClient proxy authentication', () => { + it('keeps Electron fetch when no proxy credentials are configured', async () => { + const response = await electronHttpClient.fetch('https://api.example.com/v1') + + expect(await response.text()).toBe('direct') + expect(mocks.netRequest).not.toHaveBeenCalled() + }) + + it('keeps Electron fetch for non-HTTP URLs', async () => { + setElectronProxyCredentialsForSession(mocks.defaultSession, PROXY) + + await electronHttpClient.fetch('orca-asset://icon.png') + + expect(mocks.netFetch).toHaveBeenCalledWith('orca-asset://icon.png', undefined) + expect(mocks.netRequest).not.toHaveBeenCalled() + }) + + it('answers the configured proxy challenge on a cold request', async () => { + setElectronProxyCredentialsForSession(mocks.defaultSession, PROXY) + const { pending } = await startRequest({ method: 'POST', body: 'payload', headers: { a: 'b' } }) + + const login = native.challenge({ isProxy: true, host: 'PROXY.corp', port: 8080 }) + await vi.waitFor(() => expect(native.ended).toBe(true)) + native.respond(200, 'ok') + const response = await pending + + expect(login).toHaveBeenCalledWith('user', 'secret') + expect(mocks.netRequest).toHaveBeenCalledWith( + expect.objectContaining({ session: mocks.defaultSession, method: 'POST' }) + ) + expect(native.headers.get('a')).toBe('b') + expect(Buffer.concat(native.body).toString()).toBe('payload') + expect(response.headers.get('set-cookie')).toBe('a=1, b=2') + expect(await response.text()).toBe('ok') + expect(mocks.netFetch).not.toHaveBeenCalled() + }) + + it('cancels unrelated, origin, and repeated challenges', async () => { + setElectronProxyCredentialsForSession(mocks.defaultSession, PROXY) + await startRequest() + + expect(native.challenge({ isProxy: true, host: 'other', port: 8080 })).toHaveBeenCalledWith() + + native = new FakeClientRequest() + mocks.netRequest.mockReturnValue(native) + void electronHttpClient.fetch('https://api.example.com/v1') + await vi.waitFor(() => expect(mocks.netRequest).toHaveBeenCalledTimes(2)) + expect( + native.challenge({ isProxy: false, host: 'proxy.corp', port: 8080 }) + ).toHaveBeenCalledWith() + + native = new FakeClientRequest() + mocks.netRequest.mockReturnValue(native) + void electronHttpClient.fetch('https://api.example.com/v1') + await vi.waitFor(() => expect(mocks.netRequest).toHaveBeenCalledTimes(3)) + const challenge = { isProxy: true, host: 'proxy.corp', port: 8080 } + expect(native.challenge(challenge)).toHaveBeenCalledWith('user', 'secret') + expect(native.challenge(challenge)).toHaveBeenCalledWith() + }) + + it('waits for a pending proxy policy to install its credentials', async () => { + let settle: (ready: boolean) => void = () => {} + mocks.readiness.mockReturnValue(new Promise((resolve) => (settle = resolve))) + const pending = electronHttpClient.fetch('https://api.example.com/v1') + + setElectronProxyCredentialsForSession(mocks.defaultSession, PROXY) + settle(true) + await vi.waitFor(() => expect(mocks.netRequest).toHaveBeenCalled()) + native.respond(204, '') + + expect((await pending).body).toBeNull() + expect(mocks.netFetch).not.toHaveBeenCalled() + }) + + it('rejects with the caller abort reason and aborts the native request', async () => { + setElectronProxyCredentialsForSession(mocks.defaultSession, PROXY) + const controller = new AbortController() + const { pending } = await startRequest({ signal: controller.signal }) + + const reason = new Error('stop') + controller.abort(reason) + + await expect(pending).rejects.toBe(reason) + expect(native.abort).toHaveBeenCalled() + }) + + it('cancels a stalled response body without waiting for more data', async () => { + setElectronProxyCredentialsForSession(mocks.defaultSession, PROXY) + const { pending } = await startRequest() + const stalled = native.respond(503, null) + const response = await pending + + await response.body?.cancel() + + expect(stalled.destroyed).toBe(true) + }) +}) diff --git a/src/main/host/electron-http-client.ts b/src/main/host/electron-http-client.ts index daa538757bc4..e120783c7d5b 100644 --- a/src/main/host/electron-http-client.ts +++ b/src/main/host/electron-http-client.ts @@ -1,5 +1,41 @@ -import { net, session } from 'electron' +import { net, session, type Session } from 'electron' import type { MainHttpClient } from '../network/http-client' +import { hasElectronProxyCredentialsForSession } from '../network/electron-proxy-credentials' +import { getProxySessionApplicationReadiness } from '../network/proxy-settings' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { fetchWithProxyLogin } from './electron-proxy-login-fetch' + +function isHttpUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol) + } catch { + return false + } +} + +async function hasConfiguredProxyCredentials( + proxySession: Session, + signal: AbortSignal | undefined +): Promise { + // Credentials are installed while a proxy policy applies; a cold request must wait for it. + const readiness = getProxySessionApplicationReadiness(proxySession) + if (readiness !== true && !(await waitForPromiseWithSignal(Promise.resolve(readiness), signal))) { + return false + } + return hasElectronProxyCredentialsForSession(proxySession) +} + +async function fetchFromDefaultSession(url: string, init?: RequestInit): Promise { + const proxySession = session.defaultSession + if ( + isHttpUrl(url) && + (await hasConfiguredProxyCredentials(proxySession, init?.signal ?? undefined)) + ) { + return fetchWithProxyLogin(new Request(url, init), proxySession) + } + // Not ready falls through too: the default-session request guard cancels it. + return net.fetch(url, init) +} /** * The desktop HTTP client: Chromium's network stack, which follows session and proxy @@ -9,6 +45,6 @@ import type { MainHttpClient } from '../network/http-client' * rather than captured at install time. */ export const electronHttpClient: MainHttpClient = { - fetch: (url, init) => net.fetch(url, init), + fetch: fetchFromDefaultSession, proxySession: () => session.defaultSession } diff --git a/src/main/host/electron-proxy-login-fetch.ts b/src/main/host/electron-proxy-login-fetch.ts new file mode 100644 index 000000000000..72e69c17f34c --- /dev/null +++ b/src/main/host/electron-proxy-login-fetch.ts @@ -0,0 +1,116 @@ +import { Readable, Writable } from 'node:stream' +import { net, type Session } from 'electron' +import { getElectronProxyCredentialsForChallenge } from '../network/electron-proxy-credentials' + +const NULL_BODY_STATUSES = [101, 204, 205, 304] + +// Pull-driven so an unread body keeps backpressure on the native response. +function toWebBody(response: Readable): ReadableStream { + const chunks: AsyncIterator = response[Symbol.asyncIterator]() + return new ReadableStream({ + async pull(controller) { + const { done, value } = await chunks.next() + if (done) { + controller.close() + } else { + controller.enqueue(value) + } + }, + cancel() { + // Destroy directly: iterator return() would queue behind a pending read on a stalled body. + response.destroy() + } + }) +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The operation was aborted.', 'AbortError') +} + +/** + * Electron's net.fetch with a proxy `login` listener. Its internal request has none, so + * Electron cancels every proxy challenge and app 'login' never sees main-process requests. + * Everything else mirrors Electron 43's net.fetch. + */ +export function fetchWithProxyLogin(request: Request, proxySession: Session): Promise { + const { signal } = request + if (signal.aborted) { + void request.body?.cancel(abortReason(signal)).catch(() => {}) + return Promise.reject(abortReason(signal)) + } + return new Promise((resolve, reject) => { + const origin = request.headers.get('origin') ?? undefined + const native = net.request({ + session: proxySession, + method: request.method, + url: request.url, + origin, + credentials: + request.credentials === 'same-origin' && !origin ? 'include' : request.credentials, + cache: request.cache, + referrerPolicy: request.referrerPolicy, + redirect: request.redirect + }) + let aborted = false + signal.addEventListener( + 'abort', + () => { + aborted = true + reject(abortReason(signal)) + void request.body?.cancel(abortReason(signal)).catch(() => {}) + native.abort() + }, + { once: true } + ) + let challenged = false + native.on('login', (authInfo, callback) => { + // A repeat challenge means the proxy rejected the credentials; cancel rather than loop. + const credentials = challenged + ? null + : getElectronProxyCredentialsForChallenge(proxySession, authInfo) + challenged = true + if (credentials) { + callback(credentials.username, credentials.password) + } else { + callback() + } + }) + native.on('response', (response) => { + if (aborted) { + return + } + if (!(response instanceof Readable)) { + native.abort() + reject(new TypeError('Electron response is not a readable stream')) + return + } + const headers = new Headers() + for (const [key, value] of Object.entries(response.headers)) { + headers.set(key, Array.isArray(value) ? value.join(', ') : value) + } + const bodyless = NULL_BODY_STATUSES.includes(response.statusCode) || request.method === 'HEAD' + resolve( + new Response(bodyless ? null : toWebBody(response), { + headers, + status: response.statusCode, + statusText: response.statusMessage + }) + ) + }) + native.on('error', reject) + if (request.mode && (request.mode !== 'cors' || origin)) { + native.setHeader('Sec-Fetch-Mode', request.mode) + } + for (const [key, value] of request.headers) { + native.setHeader(key, value) + } + if (!request.body) { + native.end() + } else if (native instanceof Writable) { + void request.body.pipeTo(Writable.toWeb(native)).then(() => native.end(), reject) + } else { + native.abort() + reject(new TypeError('Electron request is not a writable stream')) + } + }) +} diff --git a/src/main/network/electron-proxy-credentials.ts b/src/main/network/electron-proxy-credentials.ts index d9ff26eb9408..8970042305dc 100644 --- a/src/main/network/electron-proxy-credentials.ts +++ b/src/main/network/electron-proxy-credentials.ts @@ -79,6 +79,22 @@ export function clearElectronProxyCredentialsForSession(proxySession: ProxySessi proxyCredentialsBySession.delete(proxySession) } +export function hasElectronProxyCredentialsForSession(proxySession: ProxySession): boolean { + return proxyCredentialsBySession.has(proxySession) +} + +export function getElectronProxyCredentialsForChallenge( + proxySession: ProxySession, + authInfo: { isProxy: boolean; host: string; port: number } +): ElectronProxyCredentials | null { + const credentials = proxyCredentialsBySession.get(proxySession) + return authInfo.isProxy && + credentials?.host === normalizeProxyHost(authInfo.host) && + credentials.port === authInfo.port + ? credentials + : null +} + export function resetElectronProxyCredentialsForTests(proxySession?: ProxySession): void { if (proxySession) { clearElectronProxyCredentialsForSession(proxySession) @@ -102,12 +118,8 @@ export function handleElectronProxyLogin( if (!proxySession) { return } - const credentials = proxyCredentialsBySession.get(proxySession) - if ( - !credentials || - credentials.host !== normalizeProxyHost(authInfo.host) || - credentials.port !== authInfo.port - ) { + const credentials = getElectronProxyCredentialsForChallenge(proxySession, authInfo) + if (!credentials) { return } event.preventDefault() From a46ddb8aeaca312f4cf7f6d7d1d2081466e31a65 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:26:29 -0700 Subject: [PATCH 26/58] fix(composer): keep current choices when a delayed review lookup returns (#26954) Co-authored-by: m4air --- .../composer-state/async-composer-state.ts | 9 +- .../src/hooks/composer-state/async-model.ts | 4 +- .../branch-start-point-actions.test.ts | 2 +- .../branch-start-point-actions.ts | 16 +- .../composer-state/composer-decisions.ts | 3 +- .../composer-deferred-source-name.test.ts | 108 +------ .../composer-state/composer-external-sync.ts | 3 +- .../composer-name-ownership.test.ts | 4 +- .../composer-name-source-selection.test.ts | 2 +- .../composer-review-base-fencing.test.ts | 303 ++++++++++++++++++ .../composer-review-base-submit-cache.test.ts | 155 +++++++++ .../composer-review-base-test-fixture.ts | 195 +++++++++++ .../composer-state/composer-source-state.ts | 20 +- .../composer-submit-source-ownership.test.ts | 219 +++++++++++++ .../github-provider-selection.ts | 15 +- .../github-submit-resolution.ts | 38 ++- .../gitlab-provider-selection.ts | 16 +- .../composer-state/issue-source-actions.ts | 16 +- .../multiple-create-reset.test.ts | 13 +- .../composer-state/project-target-actions.ts | 6 +- .../composer-state/source-identity-actions.ts | 28 +- .../source-selection-decisions.ts | 7 + .../composer-state/target-change-actions.ts | 19 +- .../work-item-source-actions.ts | 8 +- ...poserState-host-context-boundaries.test.ts | 2 +- .../useComposerState-host-retarget.test.ts | 2 + 26 files changed, 1017 insertions(+), 196 deletions(-) create mode 100644 src/renderer/src/hooks/composer-state/composer-review-base-fencing.test.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-review-base-submit-cache.test.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-submit-source-ownership.test.ts diff --git a/src/renderer/src/hooks/composer-state/async-composer-state.ts b/src/renderer/src/hooks/composer-state/async-composer-state.ts index 072550ea7acc..bf77df8cf4fb 100644 --- a/src/renderer/src/hooks/composer-state/async-composer-state.ts +++ b/src/renderer/src/hooks/composer-state/async-composer-state.ts @@ -31,7 +31,10 @@ import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' import { getRepoSetupAgentStartupPolicy } from './setup-policy-decisions' -import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' +import type { + SmartGitHubPrStartPointSelection, + SmartSourceSelection +} from './source-selection-decisions' export function useComposerAsyncState(input: ComposerAsyncStateInput) { const { @@ -162,7 +165,7 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { repoId: selectedRepo?.id ?? initialRepoId }) ) - const smartGitHubPrStartPointSelectionRef = useRef( + const smartSourceSelectionRef = useRef( initialSmartGitHubPrStartPointSelection ) @@ -265,7 +268,7 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { branchAutoNameRef, lastAutoNoteRef, noteRef, - smartGitHubPrStartPointSelectionRef, + smartSourceSelectionRef, composerRef, promptTextareaRef, promptCaretFrameRef, diff --git a/src/renderer/src/hooks/composer-state/async-model.ts b/src/renderer/src/hooks/composer-state/async-model.ts index 4b6b710f933d..2495a4d012ba 100644 --- a/src/renderer/src/hooks/composer-state/async-model.ts +++ b/src/renderer/src/hooks/composer-state/async-model.ts @@ -6,7 +6,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { GitHubRepositoryIdentity } from '../../../../shared/github/pull-request-types' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' import type { IssueCommandReadResult } from '@/runtime/runtime-hooks-client' -import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' +import type { SmartSourceSelection } from './source-selection-decisions' export type ComposerAsyncModel = { yamlHooks: OrcaHooks | null @@ -66,7 +66,7 @@ export type ComposerAsyncModel = { branchAutoNameRef: RefObject lastAutoNoteRef: RefObject noteRef: RefObject - smartGitHubPrStartPointSelectionRef: RefObject + smartSourceSelectionRef: RefObject composerRef: RefObject promptTextareaRef: RefObject promptCaretFrameRef: RefObject diff --git a/src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts b/src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts index 0a11c271ef02..2a9fbd2eb15b 100644 --- a/src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts +++ b/src/renderer/src/hooks/composer-state/branch-start-point-actions.test.ts @@ -33,7 +33,7 @@ function createInput(overrides: Partial = {}): Input { setSparseEnabled: vi.fn(), setSparseSelectedPresetId: vi.fn(), setStartFromResetHint: vi.fn(), - smartGitHubPrStartPointSelectionRef: { current: null }, + smartSourceSelectionRef: { current: null }, ...overrides } } diff --git a/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts b/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts index caa7f5e4706c..480258ec60d4 100644 --- a/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts +++ b/src/renderer/src/hooks/composer-state/branch-start-point-actions.ts @@ -27,7 +27,7 @@ type BranchStartPointActionsInput = Pick< | 'setSparseEnabled' | 'setSparseSelectedPresetId' | 'setStartFromResetHint' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' > import { useCallback } from 'react' @@ -64,7 +64,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) setSparseEnabled, setSparseSelectedPresetId, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } = input const showProjectRequiredError = useCallback((): void => { @@ -95,7 +95,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) const handleBaseBranchChange = useCallback( (next: string | undefined): void => { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null setBaseBranch(next) setBaseBranchNamesWorkspace(false) setCompareBaseRef(undefined) @@ -125,7 +125,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) setReuseEligibleBranch, setReuseSelectedBranch, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) @@ -149,6 +149,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) // Why: a Start-from PR pick is also a linkedWorkItem assignment; reuse applyLinkedWorkItem so auto-name and linkedPR stay one code path. // Provider picks already linked the source; a delayed base result must not replay its name. if (!options.sourceAlreadyLinked) { + smartSourceSelectionRef.current = null applyLinkedWorkItem(item, { preserveBranchNameOverride: Boolean(nextBranchNameOverride) }) } // Why: prefill the note from the PR (only when empty or still an auto-fill) so the sidebar surfaces it without clobbering user text. @@ -174,7 +175,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) setCompareBaseRef, setNote, setPushTarget, - setStartFromResetHint + setStartFromResetHint, + smartSourceSelectionRef ] ) @@ -195,6 +197,7 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) branchAutoNameRef.current = '' setStartFromResetHint(null) if (!options.sourceAlreadyLinked) { + smartSourceSelectionRef.current = null applyLinkedGitLabWorkItem(item) } if (item.type === 'mr') { @@ -217,7 +220,8 @@ export function useBranchStartPointActions(input: BranchStartPointActionsInput) setCompareBaseRef, setNote, setPushTarget, - setStartFromResetHint + setStartFromResetHint, + smartSourceSelectionRef ] ) diff --git a/src/renderer/src/hooks/composer-state/composer-decisions.ts b/src/renderer/src/hooks/composer-state/composer-decisions.ts index 58651fe83b48..d93d6664fe6e 100644 --- a/src/renderer/src/hooks/composer-state/composer-decisions.ts +++ b/src/renderer/src/hooks/composer-state/composer-decisions.ts @@ -175,6 +175,7 @@ export function getInitialGitHubPrStartPointSelection({ return null } return { + kind: 'github-pr', repoId, item: { ...item, type: itemIdentity.type, number: itemIdentity.number } } @@ -184,7 +185,7 @@ export function retargetGitHubPrStartPointSelection( selection: SmartGitHubPrStartPointSelection | null, repoId: string ): SmartGitHubPrStartPointSelection | null { - return selection ? { repoId, item: selection.item } : null + return selection ? { kind: 'github-pr', repoId, item: selection.item } : null } export function getMatchingLinkedTaskSourceContext( diff --git a/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts b/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts index 7dce9f21b521..479fee526ba6 100644 --- a/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts +++ b/src/renderer/src/hooks/composer-state/composer-deferred-source-name.test.ts @@ -1,50 +1,10 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' -import { useRef, useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' -import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import type { GitHubPrStartPoint } from '../../../../shared/worktree/types' -import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' -import { useGitHubSourceApplication } from './github-source-application' -import { useSourceIdentityActions } from './source-identity-actions' -import { useBranchStartPointActions } from './branch-start-point-actions' -import { useGitHubProviderSelection } from './github-provider-selection' -import { useGitLabProviderSelection } from './gitlab-provider-selection' +import { pr, mr, useReviewBaseSelectionFixture } from './composer-review-base-test-fixture' -const repo = { - id: 'fixture-repo', - path: '/repos/fixture', - displayName: 'Fixture', - badgeColor: '', - addedAt: 0 -} -const pr: GitHubWorkItem = { - id: 'pr-2', - type: 'pr', - number: 2, - title: 'Fix export', - state: 'open', - url: 'https://github.com/fixture/repo/pull/2', - labels: [], - updatedAt: '', - author: null, - repoId: repo.id -} -const mr: GitLabWorkItem = { - id: 'mr-2', - number: 2, - title: 'Fix export', - labels: [], - updatedAt: '', - author: null, - repoId: repo.id, - type: 'mr', - state: 'opened', - url: 'https://gitlab.com/fixture/repo/-/merge_requests/2' -} let originalApi: PropertyDescriptor | undefined const resolvePrBase = vi.fn() const resolveMrBase = vi.fn() @@ -73,68 +33,6 @@ function deferred() { return { promise, resolve } } -function useDeferredSource() { - const [name, setName] = useState('') - const [linkedWorkItem, setLinkedWorkItem] = useState(null) - const [baseBranch, setBaseBranch] = useState() - const common = { - name, - setName, - linkedWorkItem, - setLinkedWorkItem, - baseBranch, - setBaseBranch, - lastAutoNameRef: useRef(''), - branchAutoNameRef: useRef(''), - smartGitHubPrStartPointSelectionRef: useRef(null), - lastAutoNoteRef: useRef(''), - noteRef: useRef(''), - initialProjectGroupAppliedRef: useRef(false), - selectedRepoGitHubSourceContext: null, - branchNameOverride: undefined, - branchNameOverridePreservesNameEdits: false, - forkPushWarning: null, - pushTarget: undefined, - baseBranchNamesWorkspace: false, - isProjectGroupTarget: false, - settings: null, - eligibleRepos: [repo], - selectedRepo: repo, - setBranchNameOverride: vi.fn(), - setBranchNameOverridePreservesNameEdits: vi.fn(), - setCreateError: vi.fn(), - setForkPushWarning: vi.fn(), - setLinkDebouncedQuery: vi.fn(), - setLinkDirectItem: vi.fn(), - setLinkPopoverOpen: vi.fn(), - setLinkQuery: vi.fn(), - setLinkedGitLabIssue: vi.fn(), - setLinkedGitLabMR: vi.fn(), - setLinkedIssue: vi.fn(), - setLinkedPR: vi.fn(), - setLinkedTaskSourceContext: vi.fn(), - setPushTarget: vi.fn(), - setReuseEligibleBranch: vi.fn(), - setReuseSelectedBranch: vi.fn(), - setCompareBaseRef: vi.fn(), - setStartFromResetHint: vi.fn(), - setBaseBranchNamesWorkspace: vi.fn(), - setNote: vi.fn(), - setProjectError: vi.fn(), - setSelectedProjectGroupId: vi.fn(), - setSparseDirectories: vi.fn(), - setSparseEnabled: vi.fn(), - setSparseSelectedPresetId: vi.fn(), - handleRepoChange: vi.fn() - } - const application = useGitHubSourceApplication(common) - const identity = useSourceIdentityActions({ ...common, ...application }) - const branch = useBranchStartPointActions({ ...common, ...application, ...identity }) - const github = useGitHubProviderSelection({ ...common, ...application, ...branch }) - const gitlab = useGitLabProviderSelection({ ...common, ...identity, ...branch }) - return { ...github, ...gitlab, ...identity, ...branch, name, linkedWorkItem, baseBranch } -} - describe('name ownership while resolving review bases', () => { it.each(['github', 'gitlab'])( 'keeps an Advanced name while %s resolves its base', @@ -142,7 +40,7 @@ describe('name ownership while resolving review bases', () => { const pending = deferred() resolvePrBase.mockReturnValue(pending.promise) resolveMrBase.mockReturnValue(pending.promise) - const { result } = renderHook(useDeferredSource) + const { result } = renderHook(useReviewBaseSelectionFixture) act(() => { if (provider === 'github') { result.current.handleSmartGitHubItemSelect(pr) @@ -165,7 +63,7 @@ describe('name ownership while resolving review bases', () => { it.each(['github', 'gitlab'])( 'still links and auto-names a direct %s Start-from selection', (provider) => { - const { result } = renderHook(useDeferredSource) + const { result } = renderHook(useReviewBaseSelectionFixture) act(() => { if (provider === 'github') { result.current.handleBaseBranchPrSelect('feature/export', pr) diff --git a/src/renderer/src/hooks/composer-state/composer-external-sync.ts b/src/renderer/src/hooks/composer-state/composer-external-sync.ts index 66801c987ece..799619e38743 100644 --- a/src/renderer/src/hooks/composer-state/composer-external-sync.ts +++ b/src/renderer/src/hooks/composer-state/composer-external-sync.ts @@ -97,8 +97,7 @@ export function useComposerExternalSync(target: ComposerTargetState): ComposerEx setPushTarget: target.workspaceIdentityState.setPushTarget, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, settings: target.composerTargetStore.settings, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef }) return { hostRuntimeEffects, diff --git a/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts b/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts index db3277864fa8..8af6786ee362 100644 --- a/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts +++ b/src/renderer/src/hooks/composer-state/composer-name-ownership.test.ts @@ -12,7 +12,7 @@ import { useQuickSubmitSourcePreparation } from './quick-submit-source-preparati import * as decisions from './composer-decisions' import type { PendingSmartGitHubSubmitResolution, - SmartGitHubPrStartPointSelection + SmartSourceSelection } from './source-selection-decisions' function issue(number: number): GitHubWorkItem { @@ -49,7 +49,7 @@ function useNameOwnership(draft?: { name: string; linkedWorkItem: LinkedWorkItem setLinkedWorkItem, lastAutoNameRef, branchAutoNameRef: useRef(''), - smartGitHubPrStartPointSelectionRef: useRef(null), + smartSourceSelectionRef: useRef(null), selectedRepoGitHubSourceContext: null, branchNameOverride: undefined, branchNameOverridePreservesNameEdits: false, diff --git a/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts b/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts index 5aa4dec0efcb..b4a3e2b6709a 100644 --- a/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts +++ b/src/renderer/src/hooks/composer-state/composer-name-source-selection.test.ts @@ -50,7 +50,7 @@ function createInput(overrides: Partial = {}): Input { setReuseEligibleBranch: vi.fn(), setReuseSelectedBranch: vi.fn(), setStartFromResetHint: vi.fn(), - smartGitHubPrStartPointSelectionRef: { current: null }, + smartSourceSelectionRef: { current: null }, ...overrides } as Input } diff --git a/src/renderer/src/hooks/composer-state/composer-review-base-fencing.test.ts b/src/renderer/src/hooks/composer-state/composer-review-base-fencing.test.ts new file mode 100644 index 000000000000..1cb2f820885e --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-review-base-fencing.test.ts @@ -0,0 +1,303 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { GitHubPrStartPoint } from '../../../../shared/worktree/types' +import type { LinearIssue } from '../../../../shared/linear/issue-types' +import type { JiraIssue } from '../../../../shared/jira-types' +import { pr, mr, useReviewBaseSelectionFixture } from './composer-review-base-test-fixture' + +const linear: LinearIssue = { + id: 'linear-9', + identifier: 'ENG-9', + title: 'Current task', + url: 'https://linear.app/fixture/issue/ENG-9', + state: { name: 'Open', type: 'unstarted', color: '' }, + team: { id: 'engineering', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '' +} +const jira: JiraIssue = { + id: '9', + key: 'APP-9', + title: 'Current task', + url: 'https://jira.example/browse/APP-9', + project: { id: 'app', key: 'APP', name: 'App' }, + issueType: { id: 'bug', name: 'Bug' }, + status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To do' }, + labels: [], + updatedAt: '', + createdAt: '' +} +const staleBase: GitHubPrStartPoint = { + baseBranch: 'stale-review-base', + compareBaseRef: 'origin/stale-review-base', + pushTarget: { remoteName: 'origin', branchName: 'stale-review-base' } +} +const resolvePrBase = vi.fn() +const resolveMrBase = vi.fn() +let originalApi: PropertyDescriptor | undefined + +beforeEach(() => { + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { worktrees: { resolvePrBase, resolveMrBase } } + }) + vi.spyOn(toast, 'error').mockImplementation(() => 'fixture-toast') +}) +afterEach(() => { + vi.resetAllMocks() + vi.restoreAllMocks() + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +function deferred() { + let resolve!: (value: GitHubPrStartPoint | { error: string }) => void + let reject!: (error: Error) => void + const promise = new Promise((next, fail) => { + resolve = next + reject = fail + }) + return { promise, resolve, reject } +} +type State = ReturnType +function snapshot(state: State) { + const { + name, + linkedWorkItem, + baseBranch, + note, + pushTarget, + compareBaseRef, + repoId, + selectedProjectGroupId + } = state + return { + name, + linkedWorkItem, + baseBranch, + note, + pushTarget, + compareBaseRef, + repoId, + selectedProjectGroupId + } +} +function select(state: State, provider: string, number = 2) { + if (provider === 'github') { + state.handleSmartGitHubItemSelect({ + ...pr, + number, + url: `https://github.com/fixture/repo/pull/${number}` + }) + } else { + state.handleSmartGitLabItemSelect({ + ...mr, + number, + url: `https://gitlab.com/fixture/repo/-/merge_requests/${number}` + }) + } +} +async function complete(pending: ReturnType, outcome: string) { + await act(async () => { + if (outcome === 'success') { + pending.resolve(staleBase) + } else if (outcome === 'error-response') { + pending.resolve({ error: 'Stale review failed' }) + } else { + pending.reject(new Error('Stale review failed')) + } + await pending.promise.catch(() => undefined) + }) +} +const providers = ['github', 'gitlab'] +const outcomes = ['success', 'error-response', 'rejection'] +const actions = [ + 'unlink', + 'github-issue', + 'gitlab-issue', + 'linear', + 'jira', + 'manual-base', + 'repo-change', + 'repo-preserve-source', + 'clear-source-pill', + 'direct-github-review', + 'direct-gitlab-review', + 'folder-project', + 'folder-source-repo', + 'smart-branch' +] +// Only a GitHub PR re-resolves after a same-project host switch; a GitLab MR keeps its base. +const canceledCases = providers.flatMap((provider) => + actions + .filter((action) => provider === 'github' || action !== 'repo-preserve-source') + .flatMap((action) => outcomes.map((outcome) => ({ provider, action, outcome }))) +) + +describe('review base selection ownership', () => { + it.each(canceledCases)( + 'ignores old $provider $outcome after $action', + async ({ provider, action, outcome }) => { + const pending = deferred() + resolvePrBase.mockReturnValue(pending.promise) + resolveMrBase.mockReturnValue(pending.promise) + const { result } = renderHook(useReviewBaseSelectionFixture) + act(() => select(result.current, provider)) + act(() => result.current.handleNameValueChange('002')) + act(() => { + const state = result.current + if (action === 'unlink') { + state.handleRemoveLinkedWorkItem() + } + if (action === 'github-issue') { + state.handleSmartGitHubItemSelect({ + ...pr, + type: 'issue', + number: 347, + url: 'https://github.com/fixture/repo/issues/347' + }) + } + if (action === 'gitlab-issue') { + state.handleSmartGitLabItemSelect({ + ...mr, + type: 'issue', + number: 347, + url: 'https://gitlab.com/fixture/repo/-/issues/347' + }) + } + if (action === 'linear') { + state.handleSmartLinearIssueSelect(linear) + } + if (action === 'jira') { + state.handleSmartJiraIssueSelect(jira, { + kind: 'task-source', + provider: 'jira', + projectId: 'app', + hostId: 'local' + }) + } + if (action === 'manual-base') { + state.handleBaseBranchChange('replacement-base') + } + if (action === 'repo-change') { + state.handleRepoChange('replacement-repo') + } + if (action === 'repo-preserve-source') { + state.handleRepoChange('replacement-repo', { preserveStartFrom: true }) + } + if (action === 'clear-source-pill') { + state.handleClearSmartNameSelection() + } + if (action === 'folder-project' || action === 'folder-source-repo') { + state.handleProjectChange('project-group:folder') + } + if (action === 'smart-branch') { + state.handleSmartBranchSelect('origin/current-branch', 'current-branch') + } + if (action === 'direct-github-review') { + state.handleBaseBranchPrSelect('current-review-base', { + ...pr, + number: 3, + url: 'https://github.com/fixture/repo/pull/3' + }) + } + if (action === 'direct-gitlab-review') { + state.handleBaseBranchMrSelect('current-review-base', { + ...mr, + number: 3, + url: 'https://gitlab.com/fixture/repo/-/merge_requests/3' + }) + } + }) + if (action === 'folder-source-repo') { + act(() => result.current.handleFolderSourceRepoChange('replacement-repo')) + } + if (action === 'folder-project' || action === 'folder-source-repo') { + expect(result.current.selectedProjectGroupId).toBe('folder') + expect(result.current.linkedWorkItem).toBeNull() + } + const before = snapshot(result.current) + await complete(pending, outcome) + + expect(snapshot(result.current)).toEqual(before) + expect(toast.error).not.toHaveBeenCalled() + } + ) + + it.each(providers.flatMap((provider) => outcomes.map((outcome) => ({ provider, outcome }))))( + 'ignores replaced $provider $outcome after the newer review resolves', + async ({ provider, outcome }) => { + const old = deferred() + const current = deferred() + resolvePrBase.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + resolveMrBase.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const { result } = renderHook(useReviewBaseSelectionFixture) + act(() => select(result.current, provider)) + act(() => select(result.current, provider, 3)) + await act(async () => { + current.resolve({ baseBranch: 'current-review-base' }) + await current.promise + }) + const before = snapshot(result.current) + await complete(old, outcome) + + expect(snapshot(result.current)).toEqual(before) + expect(result.current.baseBranch).toBe('current-review-base') + expect(toast.error).not.toHaveBeenCalled() + } + ) + + it.each(providers)('applies a current %s success', async (provider) => { + const pending = deferred() + resolvePrBase.mockReturnValue(pending.promise) + resolveMrBase.mockReturnValue(pending.promise) + const { result } = renderHook(useReviewBaseSelectionFixture) + act(() => select(result.current, provider)) + await complete(pending, 'success') + + expect(result.current.baseBranch).toBe(staleBase.baseBranch) + expect(result.current.pushTarget).toEqual(staleBase.pushTarget) + expect(result.current.compareBaseRef).toBe(staleBase.compareBaseRef) + expect(result.current.note).toBe( + provider === 'github' ? 'PR #2 — Fix export' : 'MR !2 — Fix export' + ) + expect(toast.error).not.toHaveBeenCalled() + }) + + it.each( + providers.flatMap((provider) => + ['error-response', 'rejection'].map((outcome) => ({ provider, outcome })) + ) + )('reports a current $provider $outcome', async ({ provider, outcome }) => { + const pending = deferred() + resolvePrBase.mockReturnValue(pending.promise) + resolveMrBase.mockReturnValue(pending.promise) + const { result } = renderHook(useReviewBaseSelectionFixture) + act(() => select(result.current, provider)) + await complete(pending, outcome) + + expect(toast.error).toHaveBeenCalledWith('Stale review failed') + expect(result.current.baseBranch).toBeUndefined() + }) + + it('keeps a resolved GitLab MR base when the same project switches host', async () => { + const pending = deferred() + resolveMrBase.mockReturnValue(pending.promise) + const { result } = renderHook(useReviewBaseSelectionFixture) + act(() => select(result.current, 'gitlab')) + await complete(pending, 'success') + act(() => result.current.handleRepoChange('replacement-repo', { preserveStartFrom: true })) + + expect(result.current.baseBranch).toBe(staleBase.baseBranch) + expect(result.current.compareBaseRef).toBe(staleBase.compareBaseRef) + }) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-review-base-submit-cache.test.ts b/src/renderer/src/hooks/composer-state/composer-review-base-submit-cache.test.ts new file mode 100644 index 000000000000..03d51012792e --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-review-base-submit-cache.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPrStartPoint } from '../../../../shared/worktree/types' +import { pr, useReviewBaseSelectionFixture } from './composer-review-base-test-fixture' + +const resolvePrBase = vi.fn() +const resolveMrBase = vi.fn() +let originalApi: PropertyDescriptor | undefined +beforeEach(() => { + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { worktrees: { resolvePrBase, resolveMrBase } } + }) +}) +afterEach(() => { + vi.resetAllMocks() + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +describe('GitHub review base submit cache', () => { + it('keeps a direct Start-from result without a pending lookup token', async () => { + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleBaseBranchPrSelect('direct-review-base', pr)) + const resolution = await result.current.resolvePendingSmartGitHubSubmit() + + expect(resolution).toEqual({ kind: 'none' }) + expect(result.current.baseBranch).toBe('direct-review-base') + expect(result.current.linkedWorkItem).toMatchObject({ type: 'pr', number: 2 }) + expect(resolvePrBase).not.toHaveBeenCalled() + }) + + it('reuses the completed current selection without another lookup', async () => { + resolvePrBase.mockResolvedValue({ baseBranch: 'current-review-base' }) + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + await act(async () => { + result.current.handleSmartGitHubItemSelect(pr) + }) + const resolution = await result.current.resolvePendingSmartGitHubSubmit() + + expect(resolution).toMatchObject({ kind: 'pr-start-point', baseBranch: 'current-review-base' }) + expect(resolvePrBase).toHaveBeenCalledTimes(1) + }) + + it('still resolves a seeded initial pull request on submit', async () => { + resolvePrBase.mockResolvedValue({ baseBranch: 'seeded-review-base' }) + const { result } = renderHook(() => useReviewBaseSelectionFixture(pr)) + let resolution + await act(async () => { + resolution = await result.current.resolvePendingSmartGitHubSubmit() + }) + + expect(resolution).toMatchObject({ kind: 'pr-start-point', baseBranch: 'seeded-review-base' }) + expect(resolvePrBase).toHaveBeenCalledTimes(1) + expect(result.current.baseBranch).toBe('seeded-review-base') + }) + + it('still resolves a selected pull request if Create is pressed before its lookup finishes', async () => { + let complete!: (value: GitHubPrStartPoint) => void + const pending = new Promise((resolve) => { + complete = resolve + }) + resolvePrBase + .mockReturnValueOnce(pending) + .mockResolvedValueOnce({ baseBranch: 'current-review-base' }) + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleSmartGitHubItemSelect(pr)) + let resolution + await act(async () => { + resolution = await result.current.resolvePendingSmartGitHubSubmit() + }) + + expect(resolution).toMatchObject({ kind: 'pr-start-point', baseBranch: 'current-review-base' }) + expect(resolvePrBase).toHaveBeenCalledTimes(2) + await act(async () => { + complete({ baseBranch: 'current-review-base' }) + await pending + }) + expect(result.current.baseBranch).toBe('current-review-base') + }) + + it.each(['source-change', 'base-change', 'repository-change', 'unlink', 'folder-project'])( + 'returns the captured creation result without publishing stale form state after %s', + async (action) => { + let finish!: (value: GitHubPrStartPoint) => void + const submitBase = new Promise((resolve) => { + finish = resolve + }) + resolvePrBase + .mockReturnValueOnce(new Promise(() => undefined)) + .mockReturnValueOnce(submitBase) + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleSmartGitHubItemSelect(pr)) + let pending!: ReturnType + act(() => { + pending = result.current.resolvePendingSmartGitHubSubmit() + }) + act(() => { + if (action === 'source-change') { + result.current.handleSmartGitHubItemSelect({ + ...pr, + type: 'issue', + number: 347, + url: 'https://github.com/fixture/repo/issues/347' + }) + } + if (action === 'base-change') { + result.current.handleBaseBranchChange('replacement-base') + } + if (action === 'repository-change') { + result.current.handleRepoChange('replacement-repo') + } + if (action === 'unlink') { + result.current.handleRemoveLinkedWorkItem() + } + if (action === 'folder-project') { + result.current.handleProjectChange('project-group:folder') + } + }) + const before = { + source: result.current.linkedWorkItem, + base: result.current.baseBranch, + push: result.current.pushTarget, + compare: result.current.compareBaseRef + } + let resolution + await act(async () => { + finish({ + baseBranch: 'submitted-review-base', + compareBaseRef: 'origin/submitted-review-base', + pushTarget: { remoteName: 'origin', branchName: 'submitted-review-base' } + }) + resolution = await pending + }) + + expect(resolution).toMatchObject({ + kind: 'pr-start-point', + baseBranch: 'submitted-review-base', + linkedPR: 2 + }) + expect({ + source: result.current.linkedWorkItem, + base: result.current.baseBranch, + push: result.current.pushTarget, + compare: result.current.compareBaseRef + }).toEqual(before) + } + ) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts b/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts new file mode 100644 index 000000000000..662da6b8f2de --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-review-base-test-fixture.ts @@ -0,0 +1,195 @@ +import { useRef, useState } from 'react' +import { vi } from 'vitest' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { SmartSourceSelection } from './source-selection-decisions' +import { useGitHubSourceApplication } from './github-source-application' +import { useSourceIdentityActions } from './source-identity-actions' +import { useBranchStartPointActions } from './branch-start-point-actions' +import { useGitHubProviderSelection } from './github-provider-selection' +import { useGitLabProviderSelection } from './gitlab-provider-selection' +import { useIssueSourceActions } from './issue-source-actions' +import { useTargetChangeActions } from './target-change-actions' +import { useProjectTargetActions } from './project-target-actions' +import { useWorkItemSourceActions } from './work-item-source-actions' +import { useGitHubSubmitResolution } from './github-submit-resolution' +import * as decisions from './composer-decisions' + +export const repo = { + id: 'fixture-repo', + path: '/repos/fixture', + displayName: 'Fixture', + badgeColor: '', + addedAt: 0 +} +const folderGroup: ProjectGroup = { + id: 'folder', + name: 'Folder', + parentPath: '/repos', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 +} +const replacementRepo = { ...repo, id: 'replacement-repo', path: '/repos/replacement' } +export const pr: GitHubWorkItem = { + id: 'pr-2', + type: 'pr', + number: 2, + title: 'Fix export', + state: 'open', + url: 'https://github.com/fixture/repo/pull/2', + labels: [], + updatedAt: '', + author: null, + repoId: repo.id +} +export const mr: GitLabWorkItem = { + id: 'mr-2', + number: 2, + title: 'Fix export', + labels: [], + updatedAt: '', + author: null, + repoId: repo.id, + type: 'mr', + state: 'opened', + url: 'https://gitlab.com/fixture/repo/-/merge_requests/2' +} +export function useReviewBaseSelectionFixture(initialReview?: GitHubWorkItem) { + const [name, setName] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState( + initialReview ?? null + ) + const [baseBranch, setBaseBranch] = useState() + const [repoId, setRepoId] = useState(repo.id) + const [selectedProjectGroupId, setSelectedProjectGroupId] = useState(null) + const [note, setNote] = useState('') + const [pushTarget, setPushTarget] = useState() + const [compareBaseRef, setCompareBaseRef] = useState() + const [branchNameOverride, setBranchNameOverride] = useState() + const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = + useState(false) + const [initialSelection] = useState(() => + decisions.getInitialGitHubPrStartPointSelection({ + item: initialReview, + linkedWorkItem: initialReview, + repoId: repo.id + }) + ) + const common = { + name, + setName, + linkedWorkItem, + setLinkedWorkItem, + baseBranch, + setBaseBranch, + lastAutoNameRef: useRef(''), + branchAutoNameRef: useRef(''), + smartSourceSelectionRef: useRef(initialSelection), + lastAutoNoteRef: useRef(''), + noteRef: useRef(''), + initialProjectGroupAppliedRef: useRef(false), + selectedRepoGitHubSourceContext: null, + branchNameOverride, + branchNameOverridePreservesNameEdits, + forkPushWarning: null, + pushTarget, + baseBranchNamesWorkspace: false, + isProjectGroupTarget: selectedProjectGroupId !== null, + settings: null, + eligibleRepos: [repo, replacementRepo], + selectedRepo: repo, + selectedRepoIsGit: true, + folderSourceRepos: [], + setBranchNameOverride, + setBranchNameOverridePreservesNameEdits, + setCreateError: vi.fn(), + setForkPushWarning: vi.fn(), + setLinkDebouncedQuery: vi.fn(), + setLinkDirectItem: vi.fn(), + setLinkPopoverOpen: vi.fn(), + setLinkQuery: vi.fn(), + setLinkedGitLabIssue: vi.fn(), + setLinkedGitLabMR: vi.fn(), + setLinkedIssue: vi.fn(), + setLinkedPR: vi.fn(), + setLinkedTaskSourceContext: vi.fn(), + setPushTarget, + setReuseEligibleBranch: vi.fn(), + setReuseSelectedBranch: vi.fn(), + setCompareBaseRef, + setStartFromResetHint: vi.fn(), + setBaseBranchNamesWorkspace: vi.fn(), + setNote, + setProjectError: vi.fn(), + setSelectedProjectGroupId, + setSparseDirectories: vi.fn(), + setSparseEnabled: vi.fn(), + setSparseSelectedPresetId: vi.fn(), + handleRepoChange: vi.fn() + } + const application = useGitHubSourceApplication(common) + const identity = useSourceIdentityActions({ ...common, ...application }) + const branch = useBranchStartPointActions({ ...common, ...application, ...identity }) + const issues = useIssueSourceActions(common) + const target = useTargetChangeActions({ + ...common, + decisions, + repoId, + setRepoId, + folderSourceRepos: selectedProjectGroupId ? [repo, replacementRepo] : [], + hostOptions: [], + projectHostSetupOptions: [], + selectedRepoProjectId: null, + setSelectedProjectHostSetupOverrideId: vi.fn() + }) + const project = useProjectTargetActions({ + ...common, + handleRepoChange: target.handleRepoChange, + actionableHostIds: new Set([LOCAL_EXECUTION_HOST_ID]), + projectGroups: [folderGroup], + projectHostSetups: [], + projects: [], + repos: [repo, replacementRepo], + setRepoId, + selectedWorkspaceTarget: { status: 'unavailable', reason: 'no-eligible-repo' }, + workspaceHostScope: LOCAL_EXECUTION_HOST_ID + }) + const workItem = useWorkItemSourceActions({ + ...common, + repoId, + reuseEligibleBranch: null, + worktreesByRepo: {} + }) + const github = useGitHubProviderSelection({ ...common, ...application, ...branch }) + const gitlab = useGitLabProviderSelection({ ...common, ...identity, ...branch }) + const submit = useGitHubSubmitResolution(common) + return { + ...github, + ...gitlab, + ...identity, + ...branch, + ...issues, + ...target, + ...project, + ...workItem, + ...submit, + name, + linkedWorkItem, + baseBranch, + note, + pushTarget, + compareBaseRef, + branchNameOverride, + repoId, + selectedProjectGroupId + } +} diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts index 94516fd22e28..a24afe1a8761 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -46,8 +46,7 @@ export function useComposerSourceState( setPushTarget: target.workspaceIdentityState.setPushTarget, setReuseEligibleBranch: target.workspaceIdentityState.setReuseEligibleBranch, setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef }) const attachmentDropState = useComposerAttachmentDropState(target) const targetChangeActions = useTargetChangeActions({ @@ -83,8 +82,7 @@ export function useComposerSourceState( setSparseEnabled: target.asyncComposerState.setSparseEnabled, setSparseSelectedPresetId: target.asyncComposerState.setSparseSelectedPresetId, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef }) const projectTargetActions = useProjectTargetActions({ actionableHostIds: target.composerTargetStore.actionableHostIds, @@ -118,6 +116,7 @@ export function useComposerSourceState( setSparseEnabled: target.asyncComposerState.setSparseEnabled, setSparseSelectedPresetId: target.asyncComposerState.setSparseSelectedPresetId, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef, selectedWorkspaceTarget: target.runtimeTargetSelection.selectedWorkspaceTarget, workspaceHostScope: target.composerTargetStore.workspaceHostScope }) @@ -148,8 +147,7 @@ export function useComposerSourceState( setSparseEnabled: target.asyncComposerState.setSparseEnabled, setSparseSelectedPresetId: target.asyncComposerState.setSparseSelectedPresetId, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef }) const githubProviderSelection = useGitHubProviderSelection({ baseBranchNamesWorkspace: target.workspaceIdentityState.baseBranchNamesWorkspace, @@ -179,10 +177,10 @@ export function useComposerSourceState( setPushTarget: target.workspaceIdentityState.setPushTarget, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, settings: target.composerTargetStore.settings, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef }) const gitlabProviderSelection = useGitLabProviderSelection({ + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef, applyLinkedGitLabWorkItem: sourceIdentityActions.applyLinkedGitLabWorkItem, branchAutoNameRef: target.asyncComposerState.branchAutoNameRef, eligibleRepos: target.composerTargetStore.eligibleRepos, @@ -226,8 +224,7 @@ export function useComposerSourceState( setReuseEligibleBranch: target.workspaceIdentityState.setReuseEligibleBranch, setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef, + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef, worktreesByRepo: target.composerTargetStore.worktreesByRepo }) const issueSourceActions = useIssueSourceActions({ @@ -258,8 +255,7 @@ export function useComposerSourceState( setReuseEligibleBranch: target.workspaceIdentityState.setReuseEligibleBranch, setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch, setStartFromResetHint: target.workspaceIdentityState.setStartFromResetHint, - smartGitHubPrStartPointSelectionRef: - target.asyncComposerState.smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef: target.asyncComposerState.smartSourceSelectionRef }) const composerNavigationActions = useComposerNavigationActions({ closeModal: target.composerTargetStore.closeModal, diff --git a/src/renderer/src/hooks/composer-state/composer-submit-source-ownership.test.ts b/src/renderer/src/hooks/composer-state/composer-submit-source-ownership.test.ts new file mode 100644 index 000000000000..af8a5bf13b7e --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-submit-source-ownership.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GitHubPrStartPoint } from '../../../../shared/worktree/types' +import { clearSmartGitHubSubmitLookupCacheForTests } from '@/lib/smart-github-submit' +import { pr, useReviewBaseSelectionFixture } from './composer-review-base-test-fixture' + +const lookup = vi.fn() +const resolvePrBase = vi.fn() +let originalApi: PropertyDescriptor | undefined +const issue: GitHubWorkItem = { + ...pr, + type: 'issue', + title: 'Old review', + url: 'https://github.com/fixture/repo/issues/2' +} +beforeEach(() => { + clearSmartGitHubSubmitLookupCacheForTests() + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { gh: { workItem: lookup }, worktrees: { resolvePrBase } } + }) +}) +afterEach(() => { + vi.resetAllMocks() + clearSmartGitHubSubmitLookupCacheForTests() + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) +function deferredLookup() { + let finish!: (value: GitHubWorkItem) => void + const pending = new Promise((resolve) => { + finish = resolve + }) + lookup.mockReturnValue(pending) + return { pending, finish } +} +function snapshot(state: ReturnType) { + const { + name, + linkedWorkItem, + baseBranch, + pushTarget, + compareBaseRef, + repoId, + branchNameOverride + } = state + return { + name, + linkedWorkItem, + baseBranch, + pushTarget, + compareBaseRef, + repoId, + branchNameOverride + } +} + +describe('raw GitHub submit publication ownership', () => { + it.each([ + 'source-change', + 'same-number-selection', + 'manual-name', + 'manual-branch', + 'cleared-name', + 'unlink', + 'base-change', + 'repository-change', + 'folder-project', + 'smart-branch' + ])('returns its captured result without overwriting the form after %s', async (action) => { + const old = deferredLookup() + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleNameValueChange('#2')) + let submitted!: ReturnType + act(() => { + submitted = result.current.resolvePendingSmartGitHubSubmit() + }) + act(() => { + if (action === 'source-change' || action === 'same-number-selection') { + const number = action === 'same-number-selection' ? 2 : 347 + result.current.handleSmartGitHubItemSelect({ + ...issue, + number, + title: 'Current task', + url: `https://github.com/fixture/repo/issues/${number}` + }) + } + if (action === 'manual-name' || action === 'cleared-name') { + result.current.handleNameValueChange(action === 'manual-name' ? '002' : '') + } + if (action === 'manual-branch') { + result.current.handleBranchNameOverrideChange('manual-later-branch') + } + if (action === 'unlink') { + result.current.handleRemoveLinkedWorkItem() + } + if (action === 'base-change') { + result.current.handleBaseBranchChange('replacement-base') + } + if (action === 'repository-change') { + result.current.handleRepoChange('replacement-repo') + } + if (action === 'folder-project') { + result.current.handleProjectChange('project-group:folder') + } + if (action === 'smart-branch') { + result.current.handleSmartBranchSelect('origin/current-branch', 'current-branch') + } + }) + const before = snapshot(result.current) + let resolution + await act(async () => { + old.finish(issue) + resolution = await submitted + }) + + expect(resolution).toMatchObject({ kind: 'metadata-only', linkedIssueNumber: 2 }) + expect(snapshot(result.current)).toEqual(before) + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('publishes an active lookup when the query stays the same', async () => { + const old = deferredLookup() + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleNameValueChange('#2')) + let submitted!: ReturnType + act(() => { + submitted = result.current.resolvePendingSmartGitHubSubmit() + }) + act(() => result.current.handleNameValueChange('#2')) + await act(async () => { + old.finish(issue) + await submitted + }) + + expect(result.current.name).toBe('old-review') + expect(result.current.linkedWorkItem).toMatchObject({ number: 2, title: 'Old review' }) + }) + + it('keeps lookup deduplication for a second submission of the same query', async () => { + const old = deferredLookup() + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleNameValueChange('#2')) + let first!: ReturnType + let second!: typeof first + act(() => { + first = result.current.resolvePendingSmartGitHubSubmit() + second = result.current.resolvePendingSmartGitHubSubmit() + }) + let resolutions + await act(async () => { + old.finish(issue) + resolutions = await Promise.all([first, second]) + }) + + expect(resolutions).toEqual([ + expect.objectContaining({ linkedIssueNumber: 2 }), + expect.objectContaining({ linkedIssueNumber: 2 }) + ]) + expect(lookup).toHaveBeenCalledTimes(1) + expect(result.current.name).toBe('old-review') + expect(result.current.linkedWorkItem).toMatchObject({ number: 2 }) + }) + + it.each(['active', 'source-change'])( + 'retains the captured PR lookup result while publishing only the %s form', + async (action) => { + const itemLookup = deferredLookup() + let finishBase!: (value: GitHubPrStartPoint) => void + resolvePrBase.mockReturnValue( + new Promise((resolve) => { + finishBase = resolve + }) + ) + const { result } = renderHook(() => useReviewBaseSelectionFixture()) + act(() => result.current.handleNameValueChange('#2')) + let submitted!: ReturnType + act(() => { + submitted = result.current.resolvePendingSmartGitHubSubmit() + }) + await act(async () => { + itemLookup.finish(pr) + await itemLookup.pending + }) + expect(resolvePrBase).toHaveBeenCalledTimes(1) + if (action === 'source-change') { + act(() => + result.current.handleSmartGitHubItemSelect({ + ...issue, + number: 347, + title: 'Current task', + url: 'https://github.com/fixture/repo/issues/347' + }) + ) + } + const before = snapshot(result.current) + let resolution + await act(async () => { + finishBase({ baseBranch: 'submitted-review-base' }) + resolution = await submitted + }) + + expect(resolution).toMatchObject({ kind: 'pr-start-point', linkedPR: 2 }) + if (action === 'active') { + expect(result.current.baseBranch).toBe('submitted-review-base') + expect(result.current.linkedWorkItem).toMatchObject({ type: 'pr', number: 2 }) + } else { + expect(snapshot(result.current)).toEqual(before) + } + } + ) +}) diff --git a/src/renderer/src/hooks/composer-state/github-provider-selection.ts b/src/renderer/src/hooks/composer-state/github-provider-selection.ts index 5a40353f4362..9c303d762058 100644 --- a/src/renderer/src/hooks/composer-state/github-provider-selection.ts +++ b/src/renderer/src/hooks/composer-state/github-provider-selection.ts @@ -28,7 +28,7 @@ type GitHubProviderSelectionInput = Pick< | 'setPushTarget' | 'setStartFromResetHint' | 'settings' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' > import { useCallback } from 'react' @@ -74,11 +74,12 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) setName, setPushTarget, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } = input const handleSmartGitHubItemSelect = useCallback( (item: GitHubWorkItem): void => { + smartSourceSelectionRef.current = null const identity = resolveGitHubWorkItemIdentity(item) const normalizedItem: GitHubWorkItem = { ...item, @@ -112,7 +113,6 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' - smartGitHubPrStartPointSelectionRef.current = null // Why: provider items can come from a different source host than the run host — resolve refs against the run repo, keep item metadata for provider identity. const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId) applyLinkedWorkItem(normalizedItem) @@ -128,10 +128,11 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) setCompareBaseRef(undefined) setPushTarget(undefined) const startPointSelection: SmartGitHubPrStartPointSelection = { + kind: 'github-pr', repoId: runRepo.id, item: normalizedItem } - smartGitHubPrStartPointSelectionRef.current = startPointSelection + smartSourceSelectionRef.current = startPointSelection const resolvePrBase = resolveGitHubPrStartPointForRepo({ repoId: runRepo.id, prNumber: identity.number, @@ -144,7 +145,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) }) void resolvePrBase .then((result) => { - if (smartGitHubPrStartPointSelectionRef.current !== startPointSelection) { + if (smartSourceSelectionRef.current !== startPointSelection) { return } startPointSelection.resolved = result @@ -160,7 +161,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) setForkPushWarning(getForkPushWarning(result)) }) .catch((error: unknown) => { - if (smartGitHubPrStartPointSelectionRef.current !== startPointSelection) { + if (smartSourceSelectionRef.current !== startPointSelection) { return } setBaseBranch(undefined) @@ -199,7 +200,7 @@ export function useGitHubProviderSelection(input: GitHubProviderSelectionInput) setName, setPushTarget, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) diff --git a/src/renderer/src/hooks/composer-state/github-submit-resolution.ts b/src/renderer/src/hooks/composer-state/github-submit-resolution.ts index 32337b938dda..cca31412cdbf 100644 --- a/src/renderer/src/hooks/composer-state/github-submit-resolution.ts +++ b/src/renderer/src/hooks/composer-state/github-submit-resolution.ts @@ -27,7 +27,7 @@ type GitHubSubmitResolutionInput = Pick< | 'setPushTarget' | 'setStartFromResetHint' | 'settings' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' > import { useCallback } from 'react' @@ -78,13 +78,14 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { setName, setPushTarget, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } = input const resolvePendingSmartGitHubSubmit = useCallback(async (): Promise => { if (linkedWorkItem) { - const startPointSelection = smartGitHubPrStartPointSelectionRef.current + const reviewSelection = smartSourceSelectionRef.current + const startPointSelection = reviewSelection?.kind === 'github-pr' ? reviewSelection : null const linkedWorkItemIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem) const startPointIdentity = startPointSelection ? resolveGitHubWorkItemIdentity(startPointSelection.item) @@ -131,18 +132,20 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { ? { branchNameOverride: selectedPrStartPoint.branchNameOverride } : {}) } - setBaseBranch(selectedPrStartPoint.baseBranch) - setBaseBranchNamesWorkspace(true) - setCompareBaseRef(selectedPrStartPoint.compareBaseRef) - setPushTarget(selectedPrStartPoint.pushTarget) - if (selectedPrStartPoint.branchNameOverride) { - setBranchNameOverride(selectedPrStartPoint.branchNameOverride) - setBranchNameOverridePreservesNameEdits(true) - } else { - setBranchNameOverride(undefined) - setBranchNameOverridePreservesNameEdits(false) + if (smartSourceSelectionRef.current === startPointSelection) { + setBaseBranch(selectedPrStartPoint.baseBranch) + setBaseBranchNamesWorkspace(true) + setCompareBaseRef(selectedPrStartPoint.compareBaseRef) + setPushTarget(selectedPrStartPoint.pushTarget) + if (selectedPrStartPoint.branchNameOverride) { + setBranchNameOverride(selectedPrStartPoint.branchNameOverride) + setBranchNameOverridePreservesNameEdits(true) + } else { + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + } + setForkPushWarning(getForkPushWarning(selectedPrStartPoint)) } - setForkPushWarning(getForkPushWarning(selectedPrStartPoint)) return resolution } return { kind: 'none' } @@ -153,6 +156,8 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { return { kind: 'none' } } + const submitSelection = { kind: 'github-submit-lookup', query: name } as const + smartSourceSelectionRef.current = submitSelection const item = isProjectGroupTarget ? ( await Promise.all( @@ -218,6 +223,9 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { ...smartGitHubMetadata, kind: 'metadata-only' } + if (smartSourceSelectionRef.current !== submitSelection) { + return resolution + } // Why: Create can fire before the debounced smart field commits; commit the resolved item here so the form shows the title, not the raw URL. setLinkedIssue( resolution.linkedIssueNumber !== null ? String(resolution.linkedIssueNumber) : '' @@ -274,7 +282,7 @@ export function useGitHubSubmitResolution(input: GitHubSubmitResolutionInput) { setName, setPushTarget, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ]) return { diff --git a/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts b/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts index 1dfc9ac7ee67..6539cc8df122 100644 --- a/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts +++ b/src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts @@ -25,6 +25,7 @@ type GitLabProviderSelectionInput = Pick< | 'setName' | 'setPushTarget' | 'setStartFromResetHint' + | 'smartSourceSelectionRef' > import { useCallback } from 'react' @@ -64,12 +65,14 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) setLinkedWorkItem, setName, setPushTarget, - setStartFromResetHint + setStartFromResetHint, + smartSourceSelectionRef } = input // Why: GitLab parallel of handleSmartGitHubItemSelect — resolves MR base via worktrees:resolveMrBase (refs/merge-requests//head); issues short-circuit. const handleSmartGitLabItemSelect = useCallback( (item: GitLabWorkItem): void => { + smartSourceSelectionRef.current = null if (isProjectGroupTarget) { const linkedItem = toGitLabLinkedWorkItem(item) setLinkedGitLabIssue(item.type === 'issue' ? item.number : null) @@ -110,6 +113,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) setCompareBaseRef(undefined) return } + const startPointSelection = { kind: 'gitlab-mr', repoId: runRepo.id, item } as const + smartSourceSelectionRef.current = startPointSelection const resolveMrBase = target.kind === 'local' ? window.api.worktrees.resolveMrBase({ @@ -140,6 +145,9 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) ) void resolveMrBase .then((result) => { + if (smartSourceSelectionRef.current !== startPointSelection) { + return + } if ('error' in result) { // Why: an unsurfaced failure silently falls back to the repo default branch, so clear stale base state and toast — mirrors the GitHub PR path. setBaseBranch(undefined) @@ -157,6 +165,9 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) ) }) .catch((error: unknown) => { + if (smartSourceSelectionRef.current !== startPointSelection) { + return + } setBaseBranch(undefined) setCompareBaseRef(undefined) setPushTarget(undefined) @@ -190,7 +201,8 @@ export function useGitLabProviderSelection(input: GitLabProviderSelectionInput) setLinkedWorkItem, setName, setPushTarget, - setStartFromResetHint + setStartFromResetHint, + smartSourceSelectionRef ] ) diff --git a/src/renderer/src/hooks/composer-state/issue-source-actions.ts b/src/renderer/src/hooks/composer-state/issue-source-actions.ts index e65aadd35e8f..69ee01caeeea 100644 --- a/src/renderer/src/hooks/composer-state/issue-source-actions.ts +++ b/src/renderer/src/hooks/composer-state/issue-source-actions.ts @@ -28,7 +28,7 @@ type IssueSourceActionsInput = Pick< | 'setReuseEligibleBranch' | 'setReuseSelectedBranch' | 'setStartFromResetHint' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' > import { useCallback, useMemo } from 'react' @@ -85,11 +85,12 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } = input const handleSmartLinearIssueSelect = useCallback( (issue: LinearIssue): void => { + smartSourceSelectionRef.current = null if (isProjectGroupTarget) { const linkedItem = toLinearLinkedWorkItem(issue) setLinkedIssue('') @@ -155,12 +156,14 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { setLinkedPR, setLinkedTaskSourceContext, setLinkedWorkItem, - setName + setName, + smartSourceSelectionRef ] ) const handleSmartJiraIssueSelect = useCallback( (issue: JiraIssue, sourceContext: TaskSourceContext): void => { + smartSourceSelectionRef.current = null const linkedItem: LinkedWorkItemSummary = buildJiraWorkspaceSource(issue) setLinkedIssue('') setLinkedPR(null) @@ -211,12 +214,13 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { setLinkedTaskSourceContext, setLinkedWorkItem, setName, - setPushTarget + setPushTarget, + smartSourceSelectionRef ] ) const handleClearSmartNameSelection = useCallback((): void => { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null setLinkedIssue('') setLinkedPR(null) setLinkedGitLabIssue(null) @@ -267,7 +271,7 @@ export function useIssueSourceActions(input: IssueSourceActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ]) const smartNameSelection = useMemo(() => { diff --git a/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts b/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts index b9f68bf35505..5dbbcc9c53cd 100644 --- a/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts +++ b/src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { useIssueSourceActions } from './issue-source-actions' import { useMultipleCreateReset } from './multiple-create-reset' -import type { SmartGitHubPrStartPointSelection } from './source-selection-decisions' +import type { SmartSourceSelection } from './source-selection-decisions' const sources: LinkedWorkItemSummary[] = [ { @@ -30,9 +30,10 @@ function useSelectedSourceReset( const lastAutoNameRef = useRef(name) const branchAutoNameRef = useRef('fix-checkout') const lastAutoNoteRef = useRef('Generated note') - const smartGitHubPrStartPointSelectionRef = useRef( + const smartSourceSelectionRef = useRef( initialItem?.provider === 'github' && initialItem.type === 'pr' ? { + kind: 'github-pr', repoId: 'repo-1', item: { ...initialItem, @@ -75,7 +76,7 @@ function useSelectedSourceReset( setReuseEligibleBranch: vi.fn(), setReuseSelectedBranch: vi.fn(), setStartFromResetHint: vi.fn(), - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef }) const reset = useMultipleCreateReset({ handleClearSmartNameSelection: source.handleClearSmartNameSelection, @@ -95,7 +96,7 @@ function useSelectedSourceReset( name, note, branchAutoNameRef, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } } @@ -107,12 +108,12 @@ describe('create more source reset', () => { expect(result.current.selection?.label).toContain('Fix checkout') if (item.provider === 'github' && item.type === 'pr') { - expect(result.current.smartGitHubPrStartPointSelectionRef.current).not.toBeNull() + expect(result.current.smartSourceSelectionRef.current).not.toBeNull() } act(() => result.current.resetForNextCreate()) - expect(result.current.smartGitHubPrStartPointSelectionRef.current).toBeNull() + expect(result.current.smartSourceSelectionRef.current).toBeNull() expect(result.current.selection).toBeNull() expect(result.current.linkedWorkItem).toBeNull() expect(result.current.baseBranch).toBeUndefined() diff --git a/src/renderer/src/hooks/composer-state/project-target-actions.ts b/src/renderer/src/hooks/composer-state/project-target-actions.ts index 6ac927d01714..6850766cb5fb 100644 --- a/src/renderer/src/hooks/composer-state/project-target-actions.ts +++ b/src/renderer/src/hooks/composer-state/project-target-actions.ts @@ -32,6 +32,7 @@ type ProjectTargetActionsInput = Pick< | 'setSparseEnabled' | 'setSparseSelectedPresetId' | 'setStartFromResetHint' + | 'smartSourceSelectionRef' | 'selectedWorkspaceTarget' | 'workspaceHostScope' > @@ -78,6 +79,7 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { setSparseEnabled, setSparseSelectedPresetId, setStartFromResetHint, + smartSourceSelectionRef, selectedWorkspaceTarget, workspaceHostScope } = input @@ -103,6 +105,7 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { return } const nextSourceRepo = getFolderSourceRepos(repos, projectGroups, nextProjectGroup)[0] + smartSourceSelectionRef.current = null setSelectedProjectGroupId(nextProjectGroup.id) setProjectError(null) setRepoId(nextSourceRepo?.id ?? '') @@ -178,7 +181,8 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { setSparseDirectories, setSparseEnabled, setSparseSelectedPresetId, - setStartFromResetHint + setStartFromResetHint, + smartSourceSelectionRef ] ) diff --git a/src/renderer/src/hooks/composer-state/source-identity-actions.ts b/src/renderer/src/hooks/composer-state/source-identity-actions.ts index f41fe696e2fc..db199df2bcf4 100644 --- a/src/renderer/src/hooks/composer-state/source-identity-actions.ts +++ b/src/renderer/src/hooks/composer-state/source-identity-actions.ts @@ -29,7 +29,7 @@ type SourceIdentityActionsInput = Pick< | 'setPushTarget' | 'setReuseEligibleBranch' | 'setReuseSelectedBranch' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' > import { useCallback } from 'react' @@ -69,13 +69,13 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { setPushTarget, setReuseEligibleBranch, setReuseSelectedBranch, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } = input // Why: review routing prefers one provider identity — clear the opposite provider slots so stale hidden fields can't win later. const applyLinkedGitLabWorkItem = useCallback( (item: GitLabWorkItem): void => { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null if (item.type === 'issue') { setLinkedGitLabIssue(item.number) setLinkedGitLabMR(null) @@ -136,13 +136,13 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { setLinkedTaskSourceContext, setLinkedWorkItem, setName, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) const handleSelectLinkedItem = useCallback( (item: GitHubWorkItem): void => { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null applyLinkedWorkItem(item) setLinkPopoverOpen(false) setLinkQuery('') @@ -155,7 +155,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { setLinkDirectItem, setLinkPopoverOpen, setLinkQuery, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) @@ -172,7 +172,7 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { ) const handleRemoveLinkedWorkItem = useCallback((): void => { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem) setLinkedWorkItem(null) setLinkedTaskSourceContext(null) @@ -200,11 +200,14 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { setLinkedPR, setLinkedTaskSourceContext, setLinkedWorkItem, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ]) const handleNameValueChange = useCallback( (nextName: string): void => { + if (nextName !== name && smartSourceSelectionRef.current?.kind === 'github-submit-lookup') { + smartSourceSelectionRef.current = null + } // Why: linked items keep refreshing the suggested name only while it's auto-managed; a manual edit stops later picks from clobbering it until cleared. if (!nextName.trim()) { lastAutoNameRef.current = '' @@ -230,12 +233,16 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { lastAutoNameRef, setBranchNameOverride, setCreateError, - setName + setName, + smartSourceSelectionRef ] ) const handleBranchNameOverrideChange = useCallback( (value: string | undefined): void => { + if (smartSourceSelectionRef.current?.kind === 'github-submit-lookup') { + smartSourceSelectionRef.current = null + } const next = resolveComposerManualBranchNameChange({ value, pushTarget, @@ -258,7 +265,8 @@ export function useSourceIdentityActions(input: SourceIdentityActionsInput) { setForkPushWarning, setPushTarget, setReuseEligibleBranch, - setReuseSelectedBranch + setReuseSelectedBranch, + smartSourceSelectionRef ] ) diff --git a/src/renderer/src/hooks/composer-state/source-selection-decisions.ts b/src/renderer/src/hooks/composer-state/source-selection-decisions.ts index cb5511faf4ae..a2aec2155ee0 100644 --- a/src/renderer/src/hooks/composer-state/source-selection-decisions.ts +++ b/src/renderer/src/hooks/composer-state/source-selection-decisions.ts @@ -1,5 +1,6 @@ import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../shared/gitlab-types' import { getLinkedWorkItemProvider } from '@/lib/linked-work-item-provider' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { SmartGitHubSubmitResolution } from '@/lib/smart-github-submit' @@ -20,11 +21,17 @@ export type PendingSmartGitHubSubmitResolution = }) export type SmartGitHubPrStartPointSelection = { + kind: 'github-pr' repoId: string item: GitHubWorkItem resolved?: GitHubPrStartPoint } +export type SmartSourceSelection = + | SmartGitHubPrStartPointSelection + | { kind: 'gitlab-mr'; repoId: string; item: GitLabWorkItem } + | { kind: 'github-submit-lookup'; query: string } + export function getGitHubLinkedWorkItemIdentity( item: LinkedWorkItemSummary | null | undefined ): GitHubWorkItemIdentity | null { diff --git a/src/renderer/src/hooks/composer-state/target-change-actions.ts b/src/renderer/src/hooks/composer-state/target-change-actions.ts index e4bf8fdc37bf..596ed006ebd9 100644 --- a/src/renderer/src/hooks/composer-state/target-change-actions.ts +++ b/src/renderer/src/hooks/composer-state/target-change-actions.ts @@ -32,7 +32,7 @@ type TargetChangeActionsInput = Pick< | 'setSparseEnabled' | 'setSparseSelectedPresetId' | 'setStartFromResetHint' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' > import { useCallback } from 'react' @@ -80,7 +80,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setSparseEnabled, setSparseSelectedPresetId, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef } = input const { retargetGitHubPrStartPointSelection } = decisions @@ -117,9 +117,10 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { if (!options.preserveStartFrom) { setSelectedProjectHostSetupOverrideId(null) } - if (options.preserveStartFrom && smartGitHubPrStartPointSelectionRef.current) { - smartGitHubPrStartPointSelectionRef.current = retargetGitHubPrStartPointSelection( - smartGitHubPrStartPointSelectionRef.current, + // Why: only a GitHub PR re-resolves on the new host; other review bases are kept as before. + if (options.preserveStartFrom && smartSourceSelectionRef.current?.kind === 'github-pr') { + smartSourceSelectionRef.current = retargetGitHubPrStartPointSelection( + smartSourceSelectionRef.current, value ) setBaseBranch(undefined) @@ -131,7 +132,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setForkPushWarning(null) } if (!options.preserveStartFrom) { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null setLinkedIssue('') setLinkedPR(null) setLinkedGitLabIssue(null) @@ -191,7 +192,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setSparseEnabled, setSparseSelectedPresetId, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) @@ -201,7 +202,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { return } setRepoId(value) - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null setLinkedWorkItem((current) => current && !shouldPreserveWorkspaceSourceOnRepoChange(current) ? null : current ) @@ -223,7 +224,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { setLinkedPR, setLinkedTaskSourceContext, setLinkedWorkItem, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) diff --git a/src/renderer/src/hooks/composer-state/work-item-source-actions.ts b/src/renderer/src/hooks/composer-state/work-item-source-actions.ts index 75763f9c7723..d9c44ffeff89 100644 --- a/src/renderer/src/hooks/composer-state/work-item-source-actions.ts +++ b/src/renderer/src/hooks/composer-state/work-item-source-actions.ts @@ -18,7 +18,7 @@ type WorkItemSourceActionsInput = Pick< | 'setReuseEligibleBranch' | 'setReuseSelectedBranch' | 'setStartFromResetHint' - | 'smartGitHubPrStartPointSelectionRef' + | 'smartSourceSelectionRef' | 'worktreesByRepo' > @@ -46,13 +46,13 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef, + smartSourceSelectionRef, worktreesByRepo } = input const handleSmartBranchSelect = useCallback( (refName: string, localBranchName: string): void => { - smartGitHubPrStartPointSelectionRef.current = null + smartSourceSelectionRef.current = null const selection = resolveComposerBranchPick({ refName, localBranchName, @@ -99,7 +99,7 @@ export function useWorkItemSourceActions(input: WorkItemSourceActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setStartFromResetHint, - smartGitHubPrStartPointSelectionRef + smartSourceSelectionRef ] ) 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 9e51e8d82911..96075dca08a4 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -39,7 +39,7 @@ describe('useComposerState host-context boundaries', () => { }, repoId: 'repo-1' }) - ).toEqual({ repoId: 'repo-1', item }) + ).toEqual({ kind: 'github-pr', repoId: 'repo-1', item }) expect( getInitialGitHubPrStartPointSelection({ item, diff --git a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts index 7cf201b52df6..1bb8bb2809a3 100644 --- a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts @@ -16,6 +16,7 @@ describe('useComposerState host retarget', () => { repoId: 'repo-local' } const selection = { + kind: 'github-pr' as const, repoId: 'repo-local', item, resolved: { @@ -25,6 +26,7 @@ describe('useComposerState host retarget', () => { } expect(retargetGitHubPrStartPointSelection(selection, 'repo-ssh')).toEqual({ + kind: 'github-pr', repoId: 'repo-ssh', item }) From 12a8fc01660257032bd0ac1f99c26fdba545dccb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:30:18 -0400 Subject: [PATCH 27/58] fix(relay): report event-loop freezes and keep seat polls off the cell-list read (#27405) * fix(relay): keep seat polls off the cell-list read and report event-loop freezes The seat poller no longer waits on its database cell-list read before polling cells once it has a list, so a stalled read cannot starve the polls that feed a reserve cell's dead-man. Runtime metrics add eventLoopDelayMsMax: a single long freeze is one sample, so p99 read 68 ms across a 134 s staging freeze. Adds a pool test against a database that accepts and never answers, and a real-Postgres stop/start recovery test. * fix(relay): state the seat-poller gain precisely, justify the AddressInfo cast --- .../database-connect-stall-postgres.test.ts | 158 ++++++++++++++++++ .../relay/src/relay-observability.test.ts | 19 +++ cloud/apps/relay/src/relay-observability.ts | 5 +- .../relay/src/shadow-seat-directory.test.ts | 21 +++ cloud/apps/relay/src/shadow-seat-directory.ts | 7 +- 5 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 cloud/apps/relay/src/database-connect-stall-postgres.test.ts diff --git a/cloud/apps/relay/src/database-connect-stall-postgres.test.ts b/cloud/apps/relay/src/database-connect-stall-postgres.test.ts new file mode 100644 index 000000000000..1bc14cf132a5 --- /dev/null +++ b/cloud/apps/relay/src/database-connect-stall-postgres.test.ts @@ -0,0 +1,158 @@ +import { + connect, + createServer as createTcpServer, + type AddressInfo, + type Server, + type Socket +} from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isRelayDatabaseTransientError, openRelayDatabase, type RelayDatabase } from './database.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +// A Cloud SQL instance that is starting has an IP but never answers. The 2026-10-11 staging stall +// came from the transport blocking connect() itself, which no test can reproduce; these pin what +// the pool must do over a transport that does not block: fail fast and keep the event loop turning. +type StallingDatabase = { + port: number + // 'forward' needs a real PostgreSQL behind it. + mode: 'blackhole' | 'refuse' | 'forward' + sockets: Set + server: Server +} + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + vi.restoreAllMocks() +}) + +describe('PostgreSQL pool over a database that accepts and never answers', () => { + it('fails every caller fast and retryably without stalling the event loop', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const stalling = await stallingDatabase('blackhole') + const database = await openStallingPool(stalling, 'postgresql://relay:secret@127.0.0.1') + const loop = watchEventLoop() + + // Twice the pool: three dial, three queue behind them in the general lane. + const outcomes = await Promise.all( + Array.from({ length: 6 }, async () => { + const startedAt = performance.now() + const error = await database.query('SELECT 1').catch((failure: unknown) => failure) + return { error, elapsedMs: performance.now() - startedAt } + }) + ) + + const gapMs = loop.stop() + for (const { error, elapsedMs } of outcomes) { + expect(isRelayDatabaseTransientError(error)).toBe(true) + // A queued caller waits out one connect timeout for a slot, then one for its own dial. + expect(elapsedMs).toBeLessThan(4_500) + } + expect(stalling.sockets.size).toBeGreaterThan(0) + expect(gapMs).toBeLessThan(250) + }, 15_000) +}) + +describePostgres('PostgreSQL pool through a database stop and start', () => { + it('fails fast while the database is down or starting, then serves again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const stalling = await stallingDatabase('forward') + const database = await openStallingPool(stalling, databaseUrl!) + const loop = watchEventLoop() + expect(await database.query('SELECT 1 AS up')).toEqual([{ up: 1 }]) + + // Stopped: the instance refuses connections, and every pooled client is gone with it. + stalling.mode = 'refuse' + dropConnections(stalling) + await expectFastTransientFailure(database, 1_000) + + // Starting: the address answers TCP but PostgreSQL never speaks. + stalling.mode = 'blackhole' + await expectFastTransientFailure(database, 2_500) + + stalling.mode = 'forward' + dropConnections(stalling) + // The first caller after the start may still draw a client from the starting phase. + await expect.poll(async () => await database.query('SELECT 1 AS up').catch(() => []), { + timeout: 10_000, + interval: 100 + }).toEqual([{ up: 1 }]) + expect(await database.query('SELECT 2 AS up')).toEqual([{ up: 2 }]) + expect(loop.stop()).toBeLessThan(250) + }, 30_000) +}) + +async function expectFastTransientFailure(database: RelayDatabase, boundMs: number): Promise { + const startedAt = performance.now() + const error = await database.query('SELECT 1').catch((failure: unknown) => failure) + expect(isRelayDatabaseTransientError(error)).toBe(true) + expect(performance.now() - startedAt).toBeLessThan(boundMs) +} + +async function openStallingPool(stalling: StallingDatabase, url: string): Promise { + const viaStall = new URL(url) + viaStall.hostname = '127.0.0.1' + viaStall.port = String(stalling.port) + if (!viaStall.pathname || viaStall.pathname === '/') viaStall.pathname = '/relay' + const database = await openRelayDatabase({ + databaseUrl: viaStall.toString(), + dataDir: '', + poolMax: 3, + appliesPostgresSchema: false + }) + cleanups.push(async () => await database.close().catch(() => undefined)) + return database +} + +async function stallingDatabase(mode: StallingDatabase['mode']): Promise { + const target = databaseUrl ? new URL(databaseUrl) : undefined + const sockets = new Set() + const stalling: StallingDatabase = { port: 0, mode, sockets, server: createTcpServer() } + stalling.server.on('connection', (socket) => { + if (stalling.mode === 'refuse') { + socket.destroy() + return + } + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + socket.on('error', () => undefined) + if (stalling.mode === 'blackhole' || !target) return + const upstream = connect(Number(target.port || 5432), target.hostname) + socket.pipe(upstream).pipe(socket) + upstream.on('error', () => socket.destroy()) + socket.on('close', () => upstream.destroy()) + }) + await new Promise((resolve) => stalling.server.listen(0, '127.0.0.1', resolve)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a TCP server listening on a host and port reports an AddressInfo, never a pipe path. + stalling.port = (stalling.server.address() as AddressInfo).port + cleanups.push(async () => { + dropConnections(stalling) + await new Promise((resolve) => stalling.server.close(() => resolve())) + }) + return stalling +} + +function dropConnections(stalling: StallingDatabase): void { + for (const socket of stalling.sockets) socket.destroy() + stalling.sockets.clear() +} + +// The longest gap between 10 ms ticks: a blocked loop shows as one long gap. +function watchEventLoop(): { stop: () => number } { + let last = performance.now() + let longestGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + longestGapMs = Math.max(longestGapMs, now - last) + last = now + }, 10) + return { + stop: () => { + clearInterval(timer) + return longestGapMs + } + } +} diff --git a/cloud/apps/relay/src/relay-observability.test.ts b/cloud/apps/relay/src/relay-observability.test.ts index a993225acfe4..2517282b947a 100644 --- a/cloud/apps/relay/src/relay-observability.test.ts +++ b/cloud/apps/relay/src/relay-observability.test.ts @@ -72,6 +72,25 @@ describe('relay observability', () => { expect(RELAY_FIX_LEVEL).toBeGreaterThanOrEqual(1) }) + // Why: a director whose loop froze for 134 s still reported a 68 ms p99 (staging 2026-10-11). + it('reports one long event-loop freeze as the window max', async () => { + const entries: Array> = [] + const observability = new RelayObservability( + { role: 'director', cellId: 'director', region: 'us-central1' }, + (entry) => entries.push(entry) + ) + observability.start(() => counts, 3_600_000) + await new Promise((resolve) => setTimeout(resolve, 100)) + const frozenUntil = performance.now() + 300 + while (performance.now() < frozenUntil) { + // Holds the loop the way a blocking connect() does. + } + await new Promise((resolve) => setTimeout(resolve, 100)) + observability.flush(counts) + observability.stop() + expect(entries[0]!.eventLoopDelayMsMax).toBeGreaterThanOrEqual(250) + }) + it('emits safe readiness dependency outcomes', () => { const entries: Array> = [] const observability = new RelayObservability( diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index b24b0608ea66..17dd1ab3a441 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -485,6 +485,8 @@ export class RelayObservability implements RelayRuntimeObserver { const controlRenewalFlush = latencySummary(deltas.controlRenewalFlushLatenciesMs) const memory = process.memoryUsage() const p99 = this.eventLoop.count === 0 ? 0 : this.eventLoop.percentile(99) / 1_000_000 + // One sample covers a whole freeze, so p99 cannot see a single long one; max can. + const max = this.eventLoop.count === 0 ? 0 : this.eventLoop.max / 1_000_000 this.eventLoop.reset() this.write({ severity: 'INFO', @@ -595,7 +597,8 @@ export class RelayObservability implements RelayRuntimeObserver { httpLatencyMsMax: roundMs(deltas.httpLatencyMsMax), heapUsedBytes: memory.heapUsed, heapTotalBytes: memory.heapTotal, - eventLoopDelayMsP99: Number(p99.toFixed(3)) + eventLoopDelayMsP99: Number(p99.toFixed(3)), + eventLoopDelayMsMax: Number(max.toFixed(3)) }) } } diff --git a/cloud/apps/relay/src/shadow-seat-directory.test.ts b/cloud/apps/relay/src/shadow-seat-directory.test.ts index e57428a6b032..09cc2a352dd0 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.test.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.test.ts @@ -427,6 +427,27 @@ describe('startShadowSeatPoller', () => { expect(listCells.mock.calls.length).toBeGreaterThanOrEqual(5) }) + it('keeps polling the last cell list while a cell-list read hangs', async () => { + let finishRead: (cells: SeatFeedCell[]) => void = () => undefined + const listCells = vi + .fn<() => Promise>() + .mockResolvedValueOnce(CELLS) + .mockImplementation(() => new Promise((resolve) => (finishRead = resolve))) + const cell = poller({ 'cell-a': () => json(feed({ seq: 1, full: [] })) }, ['cell-a'], listCells) + await cell.tick() + cell.advance(31_000) + const round = cell.tick() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(listCells).toHaveBeenCalledTimes(2) + expect(cell.requests.map((url) => url.hostname)).toEqual([ + 'cell-a.example.test', + 'cell-a.example.test' + ]) + finishRead(CELLS) + await round + cell.stop() + }) + it('treats a malformed or mislabelled body as unverifiable', async () => { const cell = poller({ 'cell-a': () => json({ v: 2 }), diff --git a/cloud/apps/relay/src/shadow-seat-directory.ts b/cloud/apps/relay/src/shadow-seat-directory.ts index 6a491123e718..1e2064e650c5 100644 --- a/cloud/apps/relay/src/shadow-seat-directory.ts +++ b/cloud/apps/relay/src/shadow-seat-directory.ts @@ -741,10 +741,13 @@ export function startShadowSeatPoller( const round = async (awaitPolls: boolean): Promise => { if (stopped) return - await refreshCells() + const refresh = refreshCells() + // Only the first list is waited for: later rounds poll the last list, so the round that starts + // a refresh no longer skips its polls (one round, ~1 s, per refresh) while the read is slow. + if (cellsReadAt === undefined) await refresh // A slow cell holds only its own slot; the timer never waits on it. const polls = cells.map((cell) => poll(cell)) - if (awaitPolls) await Promise.all(polls) + if (awaitPolls) await Promise.all([refresh, ...polls]) if (now() - lastSummaryAt >= SUMMARY_INTERVAL_MS) { lastSummaryAt = now() log(JSON.stringify({ event: 'orca_relay_shadow_seat_summary', ...directory.summary(now()) })) From 2bdb55350d37c9d65763660e9876b3fdeb5ad8ce Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:30:27 -0400 Subject: [PATCH 28/58] fix(relay): director reaches Cloud SQL through a proxy sidecar (staging), adoptable deploy shape, event-loop stall alert (#27406) * fix(relay): director reaches Cloud SQL through a proxy sidecar (staging), gated event-loop stall alert On Cloud Run's built-in /cloudsql socket a connect() blocks the director's whole event loop until the connector's upstream dial ends, 10 s per attempt while the instance starts. Staging froze both directors for 134 s on 2026-10-11 and production four directors for 40-50 s on 2026-09-28. relay_director_cloud_sql_proxy_sidecar runs the Cloud SQL Auth Proxy beside the director on 127.0.0.1:5432, as the GCE cells already do, with its own TCP database URL secret. relay_event_loop_stall_alert adds the event_loop_ms_max metric and a 5 s stall alert. Both default off, so production plans unchanged; staging turns both on. * fix(relay): adopt a reviewed Terraform director shape on deploy; counter-based stall alert; cheaper probed sidecar - deploy-relay-blue-green: --shape-baseline-revision names the 0% revision a Terraform apply created; the deploy preserves that shape instead of the serving one. It must be the latest created, unserved revision on the serving image. Wired into the staging and production director workflows behind ADOPT_RELAY_DIRECTOR_TERRAFORM_SHAPE. - Event-loop stall alert is now a gated counter metric on eventLoopDelayMsMax>=5000. - Sidecar gets a /liveness probe and 0.25 vCPU (Cloud Run rejects only a total under 1 vCPU with concurrency above 1; checked with validateOnly against the API). - staging.tfvars pins the serving director's concurrency 1000 and timeout 3600 s, so the adopted apply shape does not fall to the 80 / 30 s defaults. * fix(relay): shape adoption refuses identity, topology, pruning and image changes; GOMAXPROCS=1 on the sidecar parseArguments now refuses --shape-baseline-revision with a runtime identity bootstrap, a director topology change or revision pruning, so every caller is covered, and the deploy refuses a shape baseline whose image differs from the serving one. Both director workflows also refuse the combinations up front. The proxy sidecar runs with GOMAXPROCS=1 to match its 0.25 vCPU quota. The long-hold alert comment is back above its resource. --- ...cloud-deploy-relay-production-director.yml | 16 +++ .../workflows/cloud-deploy-relay-staging.yml | 18 ++- .../terraform-root-partition/families.json | 5 + cloud/dev/scripts/deploy-relay-blue-green.mjs | 57 ++++++++- .../scripts/deploy-relay-blue-green.test.mjs | 119 +++++++++++++++++- .../terraform/environments/staging.tfvars | 8 ++ cloud/infra/terraform/relay-database.tf | 34 +++++ cloud/infra/terraform/relay-observability.tf | 80 ++++++++++++ cloud/infra/terraform/relay.tf | 106 ++++++++++++++-- cloud/infra/terraform/variables.tf | 14 ++- 10 files changed, 438 insertions(+), 19 deletions(-) diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml index 6094f25ccafc..d517fd27347f 100644 --- a/.github/workflows/cloud-deploy-relay-production-director.yml +++ b/.github/workflows/cloud-deploy-relay-production-director.yml @@ -51,6 +51,10 @@ on: description: Exact immutable serving predecessor digest for the one-time identity bootstrap required: true type: string + shape-baseline-revision: + description: Only after a Terraform apply changed the director template. The apply's 0% revision, whose shape the deploy adopts + required: false + type: string permissions: contents: read @@ -93,6 +97,7 @@ jobs: EXPECTED_REHOME_GENERATION: ${{ inputs.expected-rehome-generation }} BOOTSTRAP_RUNTIME_IDENTITY: ${{ inputs.bootstrap-runtime-identity }} PREDECESSOR_IMAGE_DIGEST: ${{ inputs.predecessor-image-digest }} + SHAPE_BASELINE_REVISION: ${{ inputs.shape-baseline-revision }} steps: - uses: actions/checkout@v4 @@ -140,7 +145,13 @@ jobs: [[ "${PREDECESSOR_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] test "${PRUNE_INCOMPATIBLE_REVISIONS}" = false test "${REGIONAL_PLACEMENT_MODE}" = preserve + test -z "${SHAPE_BASELINE_REVISION}" test "${CONFIRMATION}" = BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY + elif test -n "${SHAPE_BASELINE_REVISION}"; then + [[ "${SHAPE_BASELINE_REVISION}" =~ ^orca-cloud-relay-[0-9]{5}-[a-z0-9]{3}$ ]] + test "${PRUNE_INCOMPATIBLE_REVISIONS}" = false + test "${REGIONAL_PLACEMENT_MODE}" = preserve + test "${CONFIRMATION}" = ADOPT_RELAY_DIRECTOR_TERRAFORM_SHAPE elif test "${PRUNE_INCOMPATIBLE_REVISIONS}" = true; then test "${REGIONAL_PLACEMENT_MODE}" = preserve test "${CONFIRMATION}" = PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS @@ -222,12 +233,17 @@ jobs: fi [[ "${target_version}" =~ ^[1-9][0-9]*$ ]] RELEASE_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_SHA:0:8}" + shape_args=() + if test -n "${SHAPE_BASELINE_REVISION}"; then + shape_args=(--shape-baseline-revision "${SHAPE_BASELINE_REVISION}") + fi node dev/scripts/deploy-relay-blue-green.mjs \ --project "${GCP_PROJECT_ID}" \ --region "${GCP_REGION}" \ --service "${DIRECTOR_SERVICE_NAME}" \ --image "${IMAGE}" \ --role director \ + "${shape_args[@]}" \ --runtime-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ --predecessor-runtime-service-account "${PREDECESSOR_RUNTIME_SERVICE_ACCOUNT}" \ --bootstrap-runtime-identity "${BOOTSTRAP_RUNTIME_IDENTITY}" \ diff --git a/.github/workflows/cloud-deploy-relay-staging.yml b/.github/workflows/cloud-deploy-relay-staging.yml index 93ef7acd2640..bf24f6bab8de 100644 --- a/.github/workflows/cloud-deploy-relay-staging.yml +++ b/.github/workflows/cloud-deploy-relay-staging.yml @@ -39,7 +39,11 @@ on: type: choice options: [preserve, '0', '2'] confirmation: - description: BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY for a bootstrap + description: BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY for a bootstrap, ADOPT_RELAY_DIRECTOR_TERRAFORM_SHAPE with a shape baseline + required: false + type: string + shape-baseline-revision: + description: Only after a Terraform apply changed the director template. The apply's 0% revision, whose shape the deploy adopts required: false type: string shadow-seat-feed-cells: @@ -86,6 +90,7 @@ jobs: PREDECESSOR_IMAGE_DIGEST: ${{ inputs.predecessor-image-digest }} CAPACITY_CELL_ID: ${{ inputs.capacity-cell-id }} MIN_INSTANCES: ${{ inputs.min-instances }} + SHAPE_BASELINE_REVISION: ${{ inputs.shape-baseline-revision }} steps: - uses: actions/checkout@v4 @@ -106,6 +111,12 @@ jobs: else test -z "${PREDECESSOR_IMAGE_DIGEST}" fi + if test -n "${SHAPE_BASELINE_REVISION}"; then + [[ "${SHAPE_BASELINE_REVISION}" =~ ^orca-cloud-relay-staging-[0-9]{5}-[a-z0-9]{3}$ ]] + test "${BOOTSTRAP_RUNTIME_IDENTITY}" = false + test "${CAPACITY_CELL_ID}" = none + test "${CONFIRMATION}" = ADOPT_RELAY_DIRECTOR_TERRAFORM_SHAPE + fi - uses: hashicorp/setup-terraform@v3 with: @@ -186,6 +197,10 @@ jobs: if test "${MIN_INSTANCES}" != preserve; then floor_args=(--min-instances "${MIN_INSTANCES}") fi + shape_args=() + if test -n "${SHAPE_BASELINE_REVISION}"; then + shape_args=(--shape-baseline-revision "${SHAPE_BASELINE_REVISION}") + fi topology_args=() if test "${CAPACITY_CELL_ID}" != none; then topology_args=( @@ -207,6 +222,7 @@ jobs: "${floor_args[@]}" \ --runtime-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ "${identity_args[@]}" \ + "${shape_args[@]}" \ --rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ --rehome-audience "${REHOME_AUDIENCE}" \ --rehome-control-origin https://relay-staging.onorca.dev \ diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index 55ef3f894adb..0266bd53addd 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -142,6 +142,7 @@ "google_logging_metric.relay_cell_reserve_dead_man_tripped", "google_logging_metric.relay_control_flags_unreadable", "google_logging_metric.relay_incident", + "google_logging_metric.relay_event_loop_stall", "google_logging_metric.relay_long_cell_inventory_hold", "google_logging_metric.relay_reserve_placement_off_with_reserve_cells", "google_logging_metric.relay_snapshot", @@ -167,6 +168,7 @@ "google_monitoring_alert_policy.relay_far_cell_accept_latency", "google_monitoring_alert_policy.relay_gce_connection_headroom", "google_monitoring_alert_policy.relay_lock_timeout_burst", + "google_monitoring_alert_policy.relay_event_loop_stall", "google_monitoring_alert_policy.relay_long_cell_inventory_hold", "google_monitoring_alert_policy.relay_postgres_retry_exhausted", "google_monitoring_alert_policy.relay_process_fatal", @@ -216,6 +218,7 @@ "google_secret_manager_secret.push_provider", "google_secret_manager_secret.relay_assignment_signing_key", "google_secret_manager_secret.relay_database_url", + "google_secret_manager_secret.relay_director_database_url", "google_secret_manager_secret.relay_regional_placement_enabled", "google_secret_manager_secret_iam_member.push_dedicated_database_url_accessor", "google_secret_manager_secret_iam_member.push_provider_runtime_accessor", @@ -223,6 +226,7 @@ "google_secret_manager_secret_iam_member.relay_assignment_signing_key_director_accessor", "google_secret_manager_secret_iam_member.relay_database_url_accessor", "google_secret_manager_secret_iam_member.relay_database_url_director_accessor", + "google_secret_manager_secret_iam_member.relay_director_database_url_accessor", "google_secret_manager_secret_iam_member.relay_regional_placement_deploy_accessor", "google_secret_manager_secret_iam_member.relay_regional_placement_deploy_adder", "google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer", @@ -231,6 +235,7 @@ "google_secret_manager_secret_version.push_dedicated_database_url", "google_secret_manager_secret_version.relay_assignment_signing_key", "google_secret_manager_secret_version.relay_database_url", + "google_secret_manager_secret_version.relay_director_database_url", "google_secret_manager_secret_version.relay_regional_placement_enabled", "google_service_account.github_fence", "google_service_account.github_monitor", diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index d93b2617e86f..08a18475cabe 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -377,6 +377,28 @@ export function parseArguments(argv) { if (!['true', 'false'].includes(values['bootstrap-runtime-identity'] ?? 'false')) { throw new Error('--bootstrap-runtime-identity must be true or false') } + if ( + values['shape-baseline-revision'] !== undefined && + !/^[a-z][a-z0-9-]{0,62}$/.test(values['shape-baseline-revision']) + ) { + throw new Error('--shape-baseline-revision must be a Cloud Run revision name') + } + if (values['shape-baseline-revision'] !== undefined) { + if (values.role !== 'director') { + throw new Error('--shape-baseline-revision requires --role director') + } + // A shape adoption moves only what the reviewed apply changed: no identity, topology or + // pruning rides along, whichever caller builds the arguments. + if (values['bootstrap-runtime-identity'] === 'true') { + throw new Error('--shape-baseline-revision cannot combine with a runtime identity bootstrap') + } + if (values['capacity-cell-id'] !== undefined || values['director-cells-json'] !== undefined) { + throw new Error('--shape-baseline-revision cannot combine with a director topology change') + } + if (values['prune-revisions'] === 'true') { + throw new Error('--shape-baseline-revision cannot combine with revision pruning') + } + } if (values.role === 'director') { directorDeploymentEnvironment(values) projectServiceAccount(values, 'runtime-service-account') @@ -896,6 +918,31 @@ function assertDirectorRevisionIdentity(revision, config) { } } +// The reviewed shape a deploy must preserve. Normally the serving revision's; after a Terraform +// apply changes the template (a sidecar, a volume, a secret reference), the apply's own 0% +// revision, named by the operator, is the reviewed shape instead. +export function directorShapeBaseline(config, service, servingRevision, describe) { + const named = config['shape-baseline-revision'] + if (named === undefined) return servingRevision + if (named === activeRevision(service)) { + throw new Error('shape baseline revision is already serving; deploy without it') + } + if (named !== service.status?.latestCreatedRevisionName) { + throw new Error('shape baseline revision is not the latest created revision') + } + const servingImage = servingRevision.spec?.containers?.[0]?.image + // A shape change and an image change never ride together: each is reviewed on its own. + if (config.image?.split('@').at(-1) !== servingImage?.split('@').at(-1)) { + throw new Error('a shape baseline deploy must keep the serving image') + } + const baseline = describe(config, named) + // Terraform ignores the image, so its revision runs what is serving; anything else was not an apply. + if (baseline.spec?.containers?.[0]?.image !== servingImage) { + throw new Error('shape baseline revision does not run the serving image') + } + return baseline +} + export async function deployDirector(config, tag, overrides = {}) { const operations = { deployCandidate, @@ -916,6 +963,12 @@ export async function deployDirector(config, tag, overrides = {}) { // restating it here; public admission is per-instance, so losing it shrinks fleet capacity. const initialService = operations.describeService(config) const servingRevision = operations.describeRevision(config, activeRevision(initialService)) + const shapeBaseline = directorShapeBaseline( + config, + initialService, + servingRevision, + operations.describeRevision + ) const bootstrapRuntimeIdentity = config['bootstrap-runtime-identity'] === 'true' if (bootstrapRuntimeIdentity) { if ( @@ -1023,7 +1076,7 @@ export async function deployDirector(config, tag, overrides = {}) { const rollbackRevision = operations.describeRevision(config, rollback.revision) assertDirectorRevisionIdentity(rollbackRevision, config) assertPreservedRevisionShape( - servingRevision, + shapeBaseline, rollbackRevision, mutableEnvironment, bootstrapRuntimeIdentity @@ -1058,7 +1111,7 @@ export async function deployDirector(config, tag, overrides = {}) { const candidateRevision = operations.describeRevision(config, candidate.revision) assertDirectorRevisionIdentity(candidateRevision, config) assertPreservedRevisionShape( - servingRevision, + shapeBaseline, candidateRevision, mutableEnvironment, bootstrapRuntimeIdentity diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index 4ab531b58a7d..0ba40b6f97b1 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -441,7 +441,9 @@ function directorHarness({ reserveCells = {}, servingEnvironment = {}, placements = {}, - callerEmail + callerEmail, + // The revision template's Cloud SQL transport; a Terraform apply changes it under the serving one. + templateTransport } = {}) { const state = { activeRevision: 'relay-00001-old', @@ -468,7 +470,9 @@ function directorHarness({ } ] ]), - nextRevision: 2 + nextRevision: 2, + latestCreated: 'relay-00001-old', + templateTransport } const removed = [] const healthProtocols = [] @@ -477,6 +481,7 @@ function directorHarness({ describeService: () => ({ status: { url: 'https://relay-hash-uc.a.run.app', + latestCreatedRevisionName: state.latestCreated, traffic: [ { percent: 100, revisionName: state.activeRevision }, ...[...state.tags].map(([tag, revisionName]) => ({ @@ -493,13 +498,15 @@ function directorHarness({ metadata: { annotations: { 'autoscaling.knative.dev/minScale': String(value?.minimum ?? 0), - 'autoscaling.knative.dev/maxScale': String(value?.maximum ?? 5) + 'autoscaling.knative.dev/maxScale': String(value?.maximum ?? 5), + ...value?.transport?.annotations } }, spec: { serviceAccountName: value?.serviceAccount, containers: [ { + name: value?.transport?.name, image: value?.image, env: [ ...Object.entries(value?.env ?? {}).map(([name, value]) => ({ name, value })), @@ -508,7 +515,8 @@ function directorHarness({ valueSource: { secretKeyRef } })) ] - } + }, + ...value?.transport?.sidecars ?? [] ] } } @@ -529,8 +537,10 @@ function directorHarness({ serviceAccount: config['runtime-service-account'] ?? state.revisions.get(state.activeRevision)?.serviceAccount, - image: image ?? state.revisions.get(state.activeRevision)?.image + image: image ?? state.revisions.get(state.activeRevision)?.image, + transport: state.templateTransport }) + state.latestCreated = revision state.tags.set(tag, revision) if (deployFailure) throw deployFailure }, @@ -715,6 +725,105 @@ test('director deploy refuses to move traffic onto a candidate that lost the flo assert.equal(harness.state.activeRevision, 'relay-00001-old') }) +const SERVING_IMAGE = `relay@sha256:${'f'.repeat(64)}` + +// What a Terraform apply of relay_director_cloud_sql_proxy_sidecar puts in the template. +const SIDECAR_TRANSPORT = { + name: 'relay', + annotations: { 'run.googleapis.com/container-dependencies': '{"relay":["cloud-sql-proxy"]}' }, + sidecars: [{ name: 'cloud-sql-proxy', image: 'gcr.io/cloud-sql-connectors/cloud-sql-proxy@sha256:1' }] +} + +// The apply leaves its revision at 0%, as the director service declares no traffic block. +function applySidecarTemplate(harness) { + const serving = harness.state.revisions.get(harness.state.activeRevision) + harness.state.templateTransport = SIDECAR_TRANSPORT + harness.state.revisions.set('relay-00009-terraform', { ...serving, transport: SIDECAR_TRANSPORT }) + harness.state.latestCreated = 'relay-00009-terraform' +} + +test('a deploy after a sidecar apply refuses the new shape unless the apply revision is named', async () => { + const harness = directorHarness() + applySidecarTemplate(harness) + await assert.rejects( + deployDirector({}, 'candidate-new', harness.operations), + /unrelated revision shape/ + ) + assert.equal(harness.state.activeRevision, 'relay-00001-old') +}) + +test('a deploy adopts the shape of the named Terraform apply revision', async () => { + const harness = directorHarness() + applySidecarTemplate(harness) + await deployDirector( + { image: SERVING_IMAGE, 'shape-baseline-revision': 'relay-00009-terraform' }, + 'candidate-new', + harness.operations + ) + const active = harness.state.revisions.get(harness.state.activeRevision) + assert.equal(active.transport, SIDECAR_TRANSPORT) + assert.equal( + harness.state.revisions.get(harness.state.tags.get('selector-rollback')).transport, + SIDECAR_TRANSPORT + ) +}) + +test('the named shape baseline must be the unserved latest revision on the serving image', async () => { + const stale = directorHarness() + applySidecarTemplate(stale) + await assert.rejects( + deployDirector({ 'shape-baseline-revision': 'relay-00000-stale' }, 'candidate-new', stale.operations), + /not the latest created revision/ + ) + const serving = directorHarness() + await assert.rejects( + deployDirector({ 'shape-baseline-revision': 'relay-00001-old' }, 'candidate-new', serving.operations), + /already serving/ + ) + const otherImage = directorHarness() + applySidecarTemplate(otherImage) + otherImage.state.revisions.get('relay-00009-terraform').image = `relay@sha256:${'e'.repeat(64)}` + await assert.rejects( + deployDirector({ image: SERVING_IMAGE, 'shape-baseline-revision': 'relay-00009-terraform' }, 'candidate-new', otherImage.operations), + /does not run the serving image/ + ) + const newImage = directorHarness() + applySidecarTemplate(newImage) + await assert.rejects( + deployDirector( + { image: `relay@sha256:${'e'.repeat(64)}`, 'shape-baseline-revision': 'relay-00009-terraform' }, + 'candidate-new', + newImage.operations + ), + /must keep the serving image/ + ) + for (const harness of [stale, serving, otherImage, newImage]) { + assert.equal(harness.state.activeRevision, 'relay-00001-old') + } +}) + +test('--shape-baseline-revision takes only a revision name, and only for a director', () => { + const base = ['--project', 'p', '--region', 'r', '--service', 's', '--image', 'i@sha256:' + 'a'.repeat(64), '--release-id', 'x'] + assert.throws( + () => parseArguments([...base, '--role', 'director', '--shape-baseline-revision', 'Bad_Name']), + /must be a Cloud Run revision name/ + ) + assert.throws( + () => parseArguments([...base, '--role', 'cell', '--director-origin', 'https://d', '--admin-audience', 'a', '--shape-baseline-revision', 'relay-1']), + /--shape-baseline-revision requires --role director/ + ) + const director = [...base, '--role', 'director', '--shape-baseline-revision', 'relay-1'] + assert.equal(parseArguments(director)['shape-baseline-revision'], 'relay-1') + for (const [extra, refusal] of [ + [['--bootstrap-runtime-identity', 'true'], /cannot combine with a runtime identity bootstrap/], + [['--capacity-cell-id', 'c1', '--director-cells-json', '[]'], /cannot combine with a director topology change/], + [['--director-cells-json', '[]'], /cannot combine with a director topology change/], + [['--prune-revisions', 'true'], /cannot combine with revision pruning/] + ]) { + assert.throws(() => parseArguments([...director, ...extra]), refusal) + } +}) + test('director deploy rejects unrelated revision-shape drift', async () => { const harness = directorHarness() const describeRevision = harness.operations.describeRevision diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index d26a0ee23f53..942b6a05342a 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -18,6 +18,14 @@ relay_staging_power_auth_service_name = "orca-cloud-auth-staging" relay_base_url = "https://relay-staging.onorca.dev" relay_min_instances = 0 relay_max_instances = 2 + +# Staging proves these before production turns them on. +relay_director_cloud_sql_proxy_sidecar = true +relay_event_loop_stall_alert = true +# What the serving staging director runs (revision 00171, 2026-10-11). Pinned so the sidecar apply, +# whose revision the next deploy adopts as its shape, does not also drop it to the 80 / 30 s defaults. +relay_director_concurrency = 1000 +relay_director_request_timeout_seconds = 3600 # Staging now exercises the production-shaped GCE data plane exclusively. relay_cells = {} # Keep the stable director mapping Terraform-owned while removing cell mappings. diff --git a/cloud/infra/terraform/relay-database.tf b/cloud/infra/terraform/relay-database.tf index 5dbbd71aa313..6e3e0f1a5274 100644 --- a/cloud/infra/terraform/relay-database.tf +++ b/cloud/infra/terraform/relay-database.tf @@ -39,6 +39,40 @@ resource "google_secret_manager_secret_version" "relay_database_url" { ) } +# The director's URL when it reaches Cloud SQL through its proxy sidecar. Cells keep the socket URL. +resource "google_secret_manager_secret" "relay_director_database_url" { + count = var.relay_director_cloud_sql_proxy_sidecar ? 1 : 0 + + project = var.project_id + secret_id = "orca-cloud-relay-director-database-url" + labels = local.relay_shared_labels + + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "relay_director_database_url" { + count = var.relay_director_cloud_sql_proxy_sidecar ? 1 : 0 + + secret = google_secret_manager_secret.relay_director_database_url[0].id + secret_data = format( + "postgresql://%s:%s@127.0.0.1:5432/%s", + google_sql_user.relay.name, + random_password.relay_database.result, + google_sql_database.relay.name + ) +} + +resource "google_secret_manager_secret_iam_member" "relay_director_database_url_accessor" { + count = var.relay_director_cloud_sql_proxy_sidecar ? 1 : 0 + + project = var.project_id + secret_id = google_secret_manager_secret.relay_director_database_url[0].secret_id + role = "roles/secretmanager.secretAccessor" + member = google_service_account.relay_director_runtime.member +} + resource "google_secret_manager_secret_iam_member" "relay_database_url_accessor" { project = var.project_id secret_id = google_secret_manager_secret.relay_database_url.secret_id diff --git a/cloud/infra/terraform/relay-observability.tf b/cloud/infra/terraform/relay-observability.tf index e1f99cef4fc7..5609d223a7ac 100644 --- a/cloud/infra/terraform/relay-observability.tf +++ b/cloud/infra/terraform/relay-observability.tf @@ -850,6 +850,86 @@ resource "google_monitoring_alert_policy" "relay_cell_process_exit" { depends_on = [google_logging_metric.relay_incident] } +# One count per runtime sample whose interval held a 5 s or longer freeze. A count, not a distribution: +# a percentile of the max would hide a single freeze among healthy samples. Gated until staging +# has proved it (variables.tf). +resource "google_logging_metric" "relay_event_loop_stall" { + count = var.relay_event_loop_stall_alert ? 1 : 0 + + project = var.project_id + name = "orca_relay_event_loop_stalls" + description = "Runtime samples whose interval held an event-loop freeze of at least 5,000 ms." + filter = "${local.relay_runtime_log_filter} AND jsonPayload.eventLoopDelayMsMax>=5000" + label_extractors = { + role = "EXTRACT(jsonPayload.role)" + cell_id = "EXTRACT(jsonPayload.cellId)" + } + + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + + labels { + key = "role" + value_type = "STRING" + description = "Relay process role." + } + + labels { + key = "cell_id" + value_type = "STRING" + description = "Durable relay cell identifier, or director." + } + } +} + +resource "google_monitoring_alert_policy" "relay_event_loop_stall" { + count = var.relay_event_loop_stall_alert ? 1 : 0 + + project = var.project_id + display_name = "Orca Relay: event loop stall" + combiner = "OR" + enabled = true + # Not paging until staging has shown its noise floor. + notification_channels = [] + + dynamic "conditions" { + for_each = { + "Cloud Run" = "cloud_run_revision" + "GCE cell" = "gce_instance" + } + + content { + display_name = "Event loop froze at least 5,000 ms (${conditions.key})" + + condition_threshold { + # The metric counts only samples at or over 5,000 ms, so any count is a breach. + filter = "resource.type=\"${conditions.value}\" AND metric.type=\"logging.googleapis.com/user/${google_logging_metric.relay_event_loop_stall[0].name}\"" + comparison = "COMPARISON_GT" + threshold_value = 0 + duration = "0s" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_SUM" + cross_series_reducer = "REDUCE_SUM" + group_by_fields = ["metric.label.\"role\"", "metric.label.\"cell_id\""] + } + + trigger { + count = 1 + } + } + } + } + + documentation { + content = "A relay process froze its event loop for 5 s or more. While frozen it serves nothing, polls nothing and no timeout fires, so assigns hang and reserve cells lose their dead-man feed. On a director, look for Cloud SQL connection failures in the same window: a connect on Cloud Run's built-in /cloudsql socket blocks the loop until the connector's dial ends (staging 2026-10-11, production 2026-09-28). Otherwise look for a long synchronous task." + mime_type = "text/markdown" + } +} + # Pages on cell holds only: director holds of 1-2.5 s recur several times a day with rehoming paused, # and pausing rehome does not stop them, so paging on them would ask on-call to do nothing. resource "google_monitoring_alert_policy" "relay_long_cell_inventory_hold" { diff --git a/cloud/infra/terraform/relay.tf b/cloud/infra/terraform/relay.tf index 217f92cfd470..ae7994c5bfcc 100644 --- a/cloud/infra/terraform/relay.tf +++ b/cloud/infra/terraform/relay.tf @@ -147,16 +147,25 @@ resource "google_cloud_run_v2_service" "relay" { max_instance_count = var.relay_max_instances } - volumes { - name = "cloudsql" + # Built-in socket only without the sidecar: its connect() blocks the event loop (variables.tf). + dynamic "volumes" { + for_each = var.relay_director_cloud_sql_proxy_sidecar ? [] : ["cloudsql"] - cloud_sql_instance { - instances = [local.relay_database_connection_name] + content { + name = volumes.value + + cloud_sql_instance { + instances = [local.relay_database_connection_name] + } } } + # Stays first: deploys and readers address the relay as containers[0], and gcloud flags without + # --container land on the ingress container. containers { - image = var.relay_cloud_run_image + name = var.relay_director_cloud_sql_proxy_sidecar ? "relay" : null + image = var.relay_cloud_run_image + depends_on = var.relay_director_cloud_sql_proxy_sidecar ? ["cloud-sql-proxy"] : null env { name = "ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED" @@ -194,9 +203,13 @@ resource "google_cloud_run_v2_service" "relay" { container_port = 8080 } - volume_mounts { - name = "cloudsql" - mount_path = "/cloudsql" + dynamic "volume_mounts" { + for_each = var.relay_director_cloud_sql_proxy_sidecar ? [] : ["cloudsql"] + + content { + name = volume_mounts.value + mount_path = "/cloudsql" + } } env { @@ -204,7 +217,9 @@ resource "google_cloud_run_v2_service" "relay" { value_source { secret_key_ref { - secret = google_secret_manager_secret.relay_database_url.secret_id + secret = (var.relay_director_cloud_sql_proxy_sidecar + ? google_secret_manager_secret.relay_director_database_url[0].secret_id + : google_secret_manager_secret.relay_database_url.secret_id) version = "latest" } } @@ -380,6 +395,75 @@ resource "google_cloud_run_v2_service" "relay" { cpu_idle = false } } + + # Accepts a loopback TCP connection at once and dials Cloud SQL in the background, so the pool's + # 2 s connect timeout holds through a start-up or failover. Authenticates as the director's + # runtime account. The GCE cells run the same proxy, over a Unix socket on a full Linux kernel. + dynamic "containers" { + for_each = var.relay_director_cloud_sql_proxy_sidecar ? ["cloud-sql-proxy"] : [] + + content { + name = containers.value + image = var.relay_gce_cloud_sql_proxy_image + args = [ + "--address=127.0.0.1", + "--port=5432", + "--health-check", + # Cloud Run's probe is not documented to reach loopback; only the ingress port is routed. + "--http-address=0.0.0.0", + "--http-port=9090", + "--structured-logs", + "--exit-zero-on-sigterm", + # Lets in-flight statements finish while the relay drains after SIGTERM. + "--max-sigterm-delay=10s", + local.relay_database_connection_name + ] + + # Go sizes its scheduler to the host's cores, not the 0.25 vCPU quota; one keeps it from + # being throttled in bursts. + env { + name = "GOMAXPROCS" + value = "1" + } + + # Ready once it listens; it does not wait for the database, so the relay boots through + # an outage exactly as it does today. + startup_probe { + period_seconds = 1 + timeout_seconds = 1 + failure_threshold = 60 + + http_get { + path = "/startup" + port = 9090 + } + } + + # /liveness answers without the database; failing it means the proxy itself is wedged. + liveness_probe { + period_seconds = 10 + timeout_seconds = 1 + failure_threshold = 3 + + http_get { + path = "/liveness" + port = 9090 + } + } + + # A quarter vCPU carries the pool's few connections. Cloud Run rejects only a total under + # 1 vCPU with concurrency above 1 (validated against the API with the relay's 1 vCPU and + # concurrency 1000), so the sidecar adds 25% to director compute, not 100%. + resources { + limits = { + cpu = "0.25" + memory = "256Mi" + } + + cpu_idle = false + } + } + } } # Deploys update immutable images; Terraform owns service shape and IAM. @@ -407,7 +491,9 @@ resource "google_cloud_run_v2_service" "relay" { google_secret_manager_secret_iam_member.relay_database_url_director_accessor, google_secret_manager_secret_version.relay_assignment_signing_key, google_secret_manager_secret_version.relay_regional_placement_enabled, - google_secret_manager_secret_version.relay_database_url + google_secret_manager_secret_version.relay_database_url, + google_secret_manager_secret_iam_member.relay_director_database_url_accessor, + google_secret_manager_secret_version.relay_director_database_url ] } diff --git a/cloud/infra/terraform/variables.tf b/cloud/infra/terraform/variables.tf index 6be05489c3ff..a9ae1b7d9231 100644 --- a/cloud/infra/terraform/variables.tf +++ b/cloud/infra/terraform/variables.tf @@ -480,9 +480,21 @@ variable "relay_cloud_sql_private_ip" { default = false } +variable "relay_director_cloud_sql_proxy_sidecar" { + type = bool + description = "Reach Cloud SQL from the director through a Cloud SQL Auth Proxy sidecar on 127.0.0.1 (the GCE cells run the same proxy beside the relay, over a Unix socket), instead of Cloud Run's built-in /cloudsql socket. A connect() on the built-in socket blocks the director's whole event loop until the connector's upstream dial ends, up to 10 s per attempt while the instance starts or fails over." + default = false +} + +variable "relay_event_loop_stall_alert" { + type = bool + description = "Count runtime samples whose event-loop delay max reached 5 s and alert on any. The p99 metric reads normal through a single long freeze." + default = false +} + variable "relay_gce_cloud_sql_proxy_image" { type = string - description = "Digest-pinned Cloud SQL Auth Proxy image used by private relay workers." + description = "Digest-pinned Cloud SQL Auth Proxy image used by private relay workers and the director sidecar." default = "gcr.io/cloud-sql-connectors/cloud-sql-proxy@sha256:fc224915ef435afeb5b2a9421260a0d31986d5c8b7c7f5783c7f5d5885700cd2" validation { From c5778f35671a059d86d4c3f08fce0a4501374768 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 01:32:14 -0700 Subject: [PATCH 29/58] ci: drop the relay Windows process-tree job from desktop packaging (#27412) * ci: drop the relay Windows process-tree job from desktop packaging * ci: refuse relay-era tags in a directly dispatched mac release build --------- Co-authored-by: m4air --- .github/workflows/adhoc-mac-build.yml | 39 +++----- .github/workflows/daily-mac-build.yml | 23 ----- .github/workflows/dev-channel-win-build.yml | 22 ++--- .github/workflows/hourly-mac-build.yml | 23 ----- .github/workflows/node-server-tests.yml | 14 ++- .../workflows/relay-windows-process-tree.yml | 88 ------------------- .github/workflows/release-cut.yml | 39 ++------ .github/workflows/release-mac-build.yml | 24 +++-- .github/workflows/ssh-windows-hosts.yml | 2 - .../adhoc-build-pinned-commit.test.mjs | 8 +- ...v-channel-orcad-template-workflow.test.mjs | 1 - .../hourly-preflight-workflow.test.mjs | 2 +- .../orcad-windows-prebuild-workflow.test.mjs | 1 - .../relay-era-ref-refusal-workflow.test.mjs | 56 ++++++++++++ .../release-cut-token-permissions.test.mjs | 3 - 15 files changed, 111 insertions(+), 234 deletions(-) delete mode 100644 .github/workflows/relay-windows-process-tree.yml create mode 100644 config/scripts/relay-era-ref-refusal-workflow.test.mjs diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 0febb6f8948a..23525ee6208b 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -128,18 +128,6 @@ jobs: echo "Resolved $REQUESTED_REF -> $sha" echo "sha=$sha" >>"$GITHUB_OUTPUT" - # Why its own job: this package ships relays for Windows SSH hosts, and only a - # Windows runner compiles the addon that launches one outside sshd's job. - # Why the unvetted ref is safe here: this job holds no secrets, and the mac job - # vets the same ref before it downloads anything, so fork code never gets signed. - relay-windows-process-tree: - needs: resolve-ref - permissions: - contents: read - uses: ./.github/workflows/relay-windows-process-tree.yml - with: - ref: ${{ needs.resolve-ref.outputs.sha }} - # Why: a branch cut before the orcad template landed has none to build or ship. orcad-template-support: needs: resolve-ref @@ -156,8 +144,17 @@ jobs: ref: ${{ needs.resolve-ref.outputs.sha }} sparse-checkout: | /config/scripts/packaged-orcad-template.cjs + /config/tsconfig.relay.json sparse-checkout-cone-mode: false persist-credentials: false + # Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles. + - name: Refuse a ref that still ships the SSH relay + shell: bash + run: | + if [[ -e config/tsconfig.relay.json ]]; then + echo "::error::This ref predates #27215 and still ships the SSH relay; build a commit at or after 2b4b60a87e." + exit 1 + fi - name: Detect whether the ref ships the orcad template id: detect shell: bash @@ -171,8 +168,8 @@ jobs: # Design D2, as release-cut does: every desktop build ships the orcad template (server JS plus # every target's addons), merged from the node-server lanes that qualify each slot at this ref. - # Without it an adhoc build cannot deploy managed orcad to an SSH host. No secrets, like the - # relay job, and the mac job vets the ref before anything is signed. + # Without it an adhoc build cannot deploy managed orcad to an SSH host. No secrets, and the mac + # job vets the ref before anything is signed. orcad-template: needs: [resolve-ref, orcad-template-support] if: needs.orcad-template-support.outputs.ships == 'true' @@ -184,11 +181,10 @@ jobs: build_template: true build-adhoc-mac: - needs: [resolve-ref, relay-windows-process-tree, orcad-template-support, orcad-template] + needs: [resolve-ref, orcad-template-support, orcad-template] # Why not the implicit success(): a ref without the orcad template skips that job on purpose. if: >- !cancelled() && github.repository == 'stablyai/orca' && - needs.relay-windows-process-tree.result == 'success' && needs.orcad-template-support.result == 'success' && (needs.orcad-template.result == 'success' || (needs.orcad-template.result == 'skipped' && @@ -371,14 +367,6 @@ jobs: fi cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT" - # Only a Windows runner compiles these; the relay-windows-process-tree job - # built them for this run. - - name: Collect the Windows process-table addons for the relay - uses: actions/download-artifact@v8 - with: - name: relay-windows-process-tree - path: .build/windows-process-tree - - name: Build app run: pnpm build:release env: @@ -387,9 +375,6 @@ jobs: # gate accepts only 'stable' or 'rc', so leaving this unset keeps them # silent, which is correct for unvetted branch artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token - # Fail the build rather than ship a relay that cannot launch outside - # sshd's job for a standard user on a Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 9f013a8c7248..c50ca782c563 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -89,16 +89,6 @@ jobs: [[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Could not resolve main"; exit 1; } echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" - # Why its own job: this package ships relays for Windows SSH hosts, and only a - # Windows runner compiles the addon that launches one outside sshd's job. - relay-windows-process-tree: - needs: resolve-main - permissions: - contents: read - uses: ./.github/workflows/relay-windows-process-tree.yml - with: - ref: ${{ needs.resolve-main.outputs.head_sha }} - # As release-cut does: every SSH host runs orcad, deployed from the template this build ships. orcad-template: needs: resolve-main @@ -112,7 +102,6 @@ jobs: build-daily-mac: needs: - resolve-main - - relay-windows-process-tree - orcad-template if: github.repository == 'stablyai/orca' outputs: @@ -290,15 +279,6 @@ jobs: fi cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT" - # Only a Windows runner compiles these; the relay-windows-process-tree job - # built them for this run. - - name: Collect the Windows process-table addons for the relay - if: steps.freshness.outputs.should_build == 'true' - uses: actions/download-artifact@v8 - with: - name: relay-windows-process-tree - path: .build/windows-process-tree - - name: Build app if: steps.freshness.outputs.should_build == 'true' run: pnpm build:release @@ -308,9 +288,6 @@ jobs: # gate accepts only 'stable' or 'rc', so leaving this unset keeps them # silent, which is correct for unvetted dev artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token - # Fail the build rather than ship a relay that cannot launch outside - # sshd's job for a standard user on a Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 4445c05ecd3c..194659964863 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -180,6 +180,15 @@ jobs: # (zizmor: artipacked). persist-credentials: false + # Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles. + - name: Refuse a ref that still ships the SSH relay + shell: bash + run: | + if [[ -e config/tsconfig.relay.json ]]; then + echo "::error::This ref predates #27215 and still ships the SSH relay; build a commit at or after 2b4b60a87e." + exit 1 + fi + # Why a guard and not just a build: the workflow file comes from the # dispatch ref, but the packaging config comes from the *built* commit. A # branch cut before Windows dev builds landed has a config that ignores @@ -247,23 +256,10 @@ jobs: shell: bash run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32 - # Why here and not in build:relay: only a Windows runner can compile it, and - # arm64 cross-compiles from this same x64 agent. Runs before the 20-minute - # build so a runner image missing the MSVC ARM64 cross toolset fails in - # seconds with MSB8020 naming the component, rather than deep into packaging. - - name: Build Windows process-table addon for the relay - shell: bash - run: | - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 - - name: Build app shell: bash run: pnpm build:release env: - # Fail the build rather than ship a relay that silently falls back to - # the PowerShell scan on every Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: 'x64,arm64' # Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only # 'stable' or 'rc', so leaving it unset keeps dev builds silent — which # is correct for unvetted artifacts. Same as the mac dev channels. diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index aabfc6f1cfcc..d254ff393d5a 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -116,17 +116,6 @@ jobs: echo "main moved to $head_sha (last hourly built $last_sha); building." fi - # Why its own job: this package ships relays for Windows SSH hosts, and only a - # Windows runner compiles the addon that launches one outside sshd's job. - relay-windows-process-tree: - needs: preflight - if: needs.preflight.outputs.should_build == 'true' - permissions: - contents: read - uses: ./.github/workflows/relay-windows-process-tree.yml - with: - ref: ${{ needs.preflight.outputs.head_sha }} - # As release-cut does: every SSH host runs orcad, deployed from the template this build ships. orcad-template: needs: preflight @@ -141,7 +130,6 @@ jobs: build-hourly-mac: needs: - preflight - - relay-windows-process-tree - orcad-template if: needs.preflight.outputs.should_build == 'true' outputs: @@ -279,14 +267,6 @@ jobs: fi cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT" - # Only a Windows runner compiles these; the relay-windows-process-tree job - # built them for this run. - - name: Collect the Windows process-table addons for the relay - uses: actions/download-artifact@v8 - with: - name: relay-windows-process-tree - path: .build/windows-process-tree - - name: Build app run: pnpm build:release env: @@ -295,9 +275,6 @@ jobs: # gate accepts only 'stable' or 'rc', so leaving this unset keeps them # silent, which is correct for unvetted dev artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token - # Fail the build rather than ship a relay that cannot launch outside - # sshd's job for a standard user on a Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 66946a8e98f0..994123c0e504 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -205,14 +205,26 @@ jobs: env: ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }} BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }} - # Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay. + # Only a Windows runner compiles it; arm64 cross-compiles here. # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. + # orcad's instance lock and stop proof read a PID's creation time through it; the x64 binary this + # runner loads must answer it, and answer nothing for a missing PID rather than a stale time. - name: Build the Windows process-table addons for the desktop template if: inputs.build_template && matrix.os == 'windows-2022' shell: bash run: | node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + node -e " + const { assertWindowsProcessTreeCreationTime } = require('./config/scripts/windows-process-tree-creation-time.cjs') + const addon = require('./.build/windows-process-tree/x64/windows-process-tree.node') + assertWindowsProcessTreeCreationTime({ module: addon }) + const own = addon.getProcessCreationTime(process.pid) + const started = Date.now() - process.uptime() * 1000 + if (typeof own !== 'number' || Math.abs(own - started) > 5000) throw new Error('creation time ' + own + ' vs ' + started) + if (addon.getProcessCreationTime(0x7ffffff0) !== undefined) throw new Error('a missing PID must read undefined') + console.log('creation time ok:', own) + " - name: Keep the Windows process-table addons for the desktop template if: inputs.build_template && matrix.os == 'windows-2022' uses: actions/upload-artifact@v7 diff --git a/.github/workflows/relay-windows-process-tree.yml b/.github/workflows/relay-windows-process-tree.yml deleted file mode 100644 index a61f2fae8e9e..000000000000 --- a/.github/workflows/relay-windows-process-tree.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Relay Windows process-tree addons - -# Why a reusable workflow: every desktop package ships relays for Windows SSH hosts, -# but only a Windows runner can compile the addon that launches a relay outside -# sshd's job. macOS and Linux packaging jobs download this artifact into -# .build/windows-process-tree before `pnpm build:release` stages it. -on: - workflow_call: - inputs: - ref: - description: Commit or tag to build; must match what the packaging job checks out. - required: true - type: string - -permissions: - contents: read - -jobs: - build: - # Why windows-2022: windows-latest moved to VS 2026 before node-gyp could detect - # it. GitHub-hosted, so release-cut's SignPath provenance rule still holds. - runs-on: windows-2022 - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - persist-credentials: false - - # Plain setup rather than a composite action: `uses: ./` resolves from the - # checked-out ref, which may be a release tag that predates the action. - - name: Setup pnpm - uses: pnpm/setup@v2 - with: - install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - # Why host-only: the addon is compiled from the patched source pnpm - # materializes, and arm64 cross-compiles through node-gyp --arch. - - name: Install dependencies - uses: nick-fields/retry@v4 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile - - # The build script refuses unpatched source and checks each output's PE - # machine, ReadProcessMemory import, and spawnOutsideJob export. - - name: Build Windows process-table addons for the relay - shell: bash - run: | - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 - - # orcad's instance lock and stop proof read one PID's creation time through this addon on - # Windows SSH hosts; prove the x64 binary this runner can load answers it, and answers - # nothing for a PID that does not exist rather than a stale or zero time. - - name: Assert the addon reads process creation time - shell: bash - run: | - node -e " - const { assertWindowsProcessTreeCreationTime } = require('./config/scripts/windows-process-tree-creation-time.cjs') - const addon = require('./.build/windows-process-tree/x64/windows-process-tree.node') - assertWindowsProcessTreeCreationTime({ module: addon }) - const own = addon.getProcessCreationTime(process.pid) - const started = Date.now() - process.uptime() * 1000 - if (typeof own !== 'number' || Math.abs(own - started) > 5000) throw new Error('creation time ' + own + ' vs ' + started) - if (addon.getProcessCreationTime(0x7ffffff0) !== undefined) throw new Error('a missing PID must read undefined') - console.log('creation time ok:', own) - " - - - name: Upload relay addons - uses: actions/upload-artifact@v7 - with: - name: relay-windows-process-tree - path: .build/windows-process-tree/ - if-no-files-found: error - retention-days: 7 - # A rerun attempt re-uploads under the same name, which is otherwise refused. - overwrite: true diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 174b2e3c0117..e81fdcf04a5b 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -113,6 +113,15 @@ jobs: # `git push` overwrites the existing tag. fetch-tags: true + # Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles. + - name: Refuse a ref that still ships the SSH relay + shell: bash + run: | + if [[ -e config/tsconfig.relay.json ]]; then + echo "::error::This ref predates #27215 and still ships the SSH relay; build a commit at or after 2b4b60a87e." + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v6 with: @@ -1200,18 +1209,6 @@ jobs: - name: Confirm blocking release gates passed run: echo "All blocking release gates passed; artifact builds may start." - # Why its own job: every desktop package ships Windows relays, but only a - # Windows runner compiles the addon that launches one outside sshd's job. - # Needs only cut, so it overlaps the release gates instead of extending them. - relay-windows-process-tree: - needs: cut - if: needs.cut.outputs.should_release == 'true' - permissions: - contents: read - uses: ./.github/workflows/relay-windows-process-tree.yml - with: - ref: refs/tags/${{ needs.cut.outputs.tag }} - # Compilation can overlap the release gates; signing remains behind release-preflight. release-javascript: needs: cut @@ -1231,7 +1228,6 @@ jobs: - orcad-template - release-preflight - release-javascript - - relay-windows-process-tree # Why not the implicit success(): a tag without the orcad template skips that job on purpose. if: >- !cancelled() && @@ -1240,7 +1236,6 @@ jobs: needs.create-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-javascript.result == 'success' && - needs.relay-windows-process-tree.result == 'success' && (needs.orcad-template.result == 'success' || (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) env: @@ -1450,16 +1445,6 @@ jobs: echo "identity=$identity" >>"$GITHUB_OUTPUT" echo "Classified $TAG as $identity" - # Why every leg: each package ships relays for Windows SSH hosts. v1.4.203 and - # earlier shipped Windows relays with no windows-process-tree.node, and mac and - # Linux packages shipped none until the addon moved to its own Windows job. - # Why no run_attempt guard: Build app is ungated, so a rerun needs it staged too. - - name: Collect the Windows process-table addons for the relay - uses: actions/download-artifact@v8 - with: - name: relay-windows-process-tree - path: .build/windows-process-tree - # Why an explicit step rather than trusting the runner image: the packaged # CLI launcher is a native Rust binary, and a Windows host without cargo # fails deep inside electron-builder's native hook instead of here. Real @@ -1500,9 +1485,6 @@ jobs: ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.source_sha }} ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} - # Fail the release rather than ship a relay that cannot launch outside - # sshd's job for a standard user on a Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template @@ -2343,8 +2325,6 @@ jobs: - orcad-template - release-preflight - release-javascript - # release-mac-build.yml downloads the relay addons from this run. - - relay-windows-process-tree # Why not the implicit success(): a tag without the orcad template skips that job on purpose. if: >- !cancelled() && @@ -2353,7 +2333,6 @@ jobs: needs.create-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-javascript.result == 'success' && - needs.relay-windows-process-tree.result == 'success' && (needs.orcad-template.result == 'success' || (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) # Why: SignPath requires every job in this signing workflow to be diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index ee8a183f9e4f..c71c522b5fba 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -36,7 +36,7 @@ jobs: # faster runner. runs-on: blacksmith-6vcpu-macos-15 timeout-minutes: 60 - # actions: read fetches the relay addons from the release-cut run. + # actions: read fetches the release-cut run's artifacts. permissions: actions: read contents: write @@ -48,6 +48,15 @@ jobs: with: ref: refs/tags/${{ inputs.tag }} + # Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles. + - name: Refuse a ref that still ships the SSH relay + shell: bash + run: | + if [[ -e config/tsconfig.relay.json ]]; then + echo "::error::This ref predates #27215 and still ships the SSH relay; build a commit at or after 2b4b60a87e." + exit 1 + fi + - name: Restore draft-publish scripts from the workflow ref env: WORKFLOW_SHA: ${{ github.workflow_sha }} @@ -138,16 +147,6 @@ jobs: echo "identity=$identity" >>"$GITHUB_OUTPUT" echo "Classified $TAG as $identity" - # Only a Windows runner compiles these; release-cut's relay-windows-process-tree - # job built them from this tag before dispatching this workflow. - - name: Collect the Windows process-table addons for the relay - uses: actions/download-artifact@v8 - with: - name: relay-windows-process-tree - path: .build/windows-process-tree - run-id: ${{ inputs.release_run_id }} - github-token: ${{ github.token }} - - name: Download release JavaScript from the release run if: inputs.javascript_source_sha != '' uses: actions/download-artifact@v8 @@ -173,9 +172,6 @@ jobs: ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ inputs.javascript_source_sha }} ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} - # Fail the release rather than ship a relay that cannot launch outside - # sshd's job for a standard user on a Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # Design D2: the parent release-cut run merged it from every node-server lane at this tag. - name: Download the orcad deployment template from the release run diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index e70fd5a8d6e7..9cadb3a8b853 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -157,8 +157,6 @@ jobs: arches: ${{ matrix.arch }} - name: Build the win32 template and the relay shell: bash - env: - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.arch }} run: | pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" diff --git a/config/scripts/adhoc-build-pinned-commit.test.mjs b/config/scripts/adhoc-build-pinned-commit.test.mjs index 2768779aad0c..09333f002ebb 100644 --- a/config/scripts/adhoc-build-pinned-commit.test.mjs +++ b/config/scripts/adhoc-build-pinned-commit.test.mjs @@ -61,7 +61,6 @@ afterAll(() => rmSync(directory, { recursive: true, force: true })) describe('adhoc build pins one commit for the whole run', () => { it('checks out the pinned commit in every job that builds from the repo', () => { const { jobs } = workflow - expect(jobs['relay-windows-process-tree'].with.ref).toBe(PINNED) expect(jobs['orcad-template'].with.ref).toBe(PINNED) const support = jobs['orcad-template-support'].steps.find((step) => step.uses?.startsWith('actions/checkout@') @@ -69,12 +68,7 @@ describe('adhoc build pins one commit for the whole run', () => { expect(support.with.ref).toBe(PINNED) const vet = jobs['build-adhoc-mac'].steps.find((step) => step.id === 'vetted') expect(vet.env.REQUESTED_REF).toBe(PINNED) - for (const name of [ - 'relay-windows-process-tree', - 'orcad-template-support', - 'orcad-template', - 'build-adhoc-mac' - ]) { + for (const name of ['orcad-template-support', 'orcad-template', 'build-adhoc-mac']) { expect([jobs[name].needs].flat(), name).toContain('resolve-ref') } }) diff --git a/config/scripts/dev-channel-orcad-template-workflow.test.mjs b/config/scripts/dev-channel-orcad-template-workflow.test.mjs index 007a0a309d9a..c66b07541eb7 100644 --- a/config/scripts/dev-channel-orcad-template-workflow.test.mjs +++ b/config/scripts/dev-channel-orcad-template-workflow.test.mjs @@ -37,7 +37,6 @@ describe.each(channels)('$name build ships the orcad template', (channel) => { expect(template.permissions).toEqual({ contents: 'read' }) expect(template.secrets).toBeUndefined() expect(mac.steps.find((step) => step.name === 'Checkout').with.ref).toBe(channel.pinnedSha) - expect(jobs['relay-windows-process-tree'].with.ref).toBe(channel.pinnedSha) }) it('packages the macOS build only with the template present', () => { diff --git a/config/scripts/hourly-preflight-workflow.test.mjs b/config/scripts/hourly-preflight-workflow.test.mjs index f8e5fcdba8bf..d23681c026c7 100644 --- a/config/scripts/hourly-preflight-workflow.test.mjs +++ b/config/scripts/hourly-preflight-workflow.test.mjs @@ -62,7 +62,7 @@ describe('hourly build preflight', () => { expect( preflight.steps.find((step) => step.id === 'app_token').with['permission-contents'] ).toBe('read') - expect(build.needs).toEqual(['preflight', 'relay-windows-process-tree', 'orcad-template']) + expect(build.needs).toEqual(['preflight', 'orcad-template']) expect(build.if).toBe("needs.preflight.outputs.should_build == 'true'") expect(build.steps.find((step) => step.name === 'Checkout').with.ref).toBe( build.outputs.head_sha diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 6b5fb57c11ec..e01e346bab0a 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -203,7 +203,6 @@ describe('SSH Windows consumers of qualified server slots', () => { expect(addon).toBeLessThan(sshSteps.indexOf(sshPrepare)) expect(sshSteps.indexOf(sshPrepare)).toBeLessThan(template) expect(template).toBeLessThan(hosts) - expect(sshSteps[template].env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS).toBe('${{ matrix.arch }}') expect(sshSteps[template].run).toContain('--require-slots "win32-${{ matrix.arch }}"') expect(sshSteps[hosts].run).toContain("@('orcad-cmd','orcad-powershell')") for (const workflowPaths of [ diff --git a/config/scripts/relay-era-ref-refusal-workflow.test.mjs b/config/scripts/relay-era-ref-refusal-workflow.test.mjs new file mode 100644 index 000000000000..4307f77fc3ff --- /dev/null +++ b/config/scripts/relay-era-ref-refusal-workflow.test.mjs @@ -0,0 +1,56 @@ +// Desktop builds no longer compile the Windows relay addon, so every workflow that packages an +// arbitrary ref must refuse one that still ships the relay rather than ship it without the addon. +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { runProcess } from '@orca/process-host' + +const STEP = 'Refuse a ref that still ships the SSH relay' +const directory = mkdtempSync(join(tmpdir(), 'relay-era-ref-refusal-')) +afterAll(() => rmSync(directory, { recursive: true, force: true })) + +function steps(workflow, job) { + return parse(readFileSync(`.github/workflows/${workflow}`, 'utf8')).jobs[job].steps +} + +async function runAt(run, relayEra) { + const checkout = mkdtempSync(join(directory, 'ref-')) + mkdirSync(join(checkout, 'config')) + if (relayEra) { + writeFileSync(join(checkout, 'config', 'tsconfig.relay.json'), '{}') + } + writeFileSync(join(checkout, 'step.sh'), run) + return runProcess({ program: 'bash', args: ['step.sh'], cwd: checkout }) +} + +describe.each([ + // The refusal must precede everything that tags, signs or publishes. + [ + 'adhoc-mac-build.yml', + 'orcad-template-support', + 'Detect whether the ref ships the orcad template' + ], + ['release-cut.yml', 'cut', 'Resolve ref SHA'], + // Dispatchable on its own with any tag, so release-cut's refusal does not cover it. + ['release-mac-build.yml', 'build-mac', 'Build app'], + ['dev-channel-win-build.yml', 'build-win', 'Build app'] +])('%s refuses a relay-era ref', (workflow, job, laterStep) => { + const jobSteps = steps(workflow, job) + const index = jobSteps.findIndex((step) => step.name === STEP) + + it('checks the ref before it builds or tags anything', () => { + expect(index).toBeGreaterThan(0) + expect(jobSteps[index - 1].uses).toMatch(/^actions\/checkout@/) + const sparse = jobSteps[index - 1].with['sparse-checkout'] + expect(sparse?.split('\n').includes('/config/tsconfig.relay.json') ?? true).toBe(true) + expect(index).toBeLessThan(jobSteps.findIndex((step) => step.name === laterStep)) + }) + + it('fails only when the checkout still has the relay', async () => { + const run = jobSteps[index].run + expect((await runAt(run, true)).code).toBe(1) + expect((await runAt(run, false)).code).toBe(0) + }) +}) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index d58604b63a69..d60da99a1ef7 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -68,9 +68,6 @@ const EXPECTED_MATRIX = { contents: 'read' }, [`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' }, - [`${RELEASE_WORKFLOW}#relay-windows-process-tree`]: { contents: 'read' }, - [`${RELEASE_WORKFLOW}#relay-windows-process-tree -> .github/workflows/relay-windows-process-tree.yml#build`]: - { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' }, From 264dc343d69443d79b4359a6d0a7a31d49398c4e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 01:32:55 -0700 Subject: [PATCH 30/58] fix(automations): find folder-workspace targets on the Automations page (#27416) Co-authored-by: m4air --- .../automation-list-row-identity.test.ts | 20 +++++++- .../automation-list-row-identity.ts | 48 +++++++++++++++---- ...tomations-page-store-state.folder.test.tsx | 28 +++++++++++ .../use-automations-page-store-state.ts | 5 +- .../listing/detected-worktree-meta.ts | 22 +++++---- 5 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx diff --git a/src/renderer/src/components/automations/automation-list-row-identity.test.ts b/src/renderer/src/components/automations/automation-list-row-identity.test.ts index 4dbc19b6944d..0bce65d4457e 100644 --- a/src/renderer/src/components/automations/automation-list-row-identity.test.ts +++ b/src/renderer/src/components/automations/automation-list-row-identity.test.ts @@ -25,7 +25,12 @@ import { matchAutomationListSearchRowKeys } from './automation-list-search-rows' import { REPO_ID } from './automations-page-fixtures' -import { automationRepoForRow, automationWorktreeForRow } from './automation-list-row-identity' +import { makeFolderWorkspace } from '../sidebar/folder-workspace-composer-submit-fixtures' +import { + automationFolderWorktreeForRow, + automationRepoForRow, + automationWorktreeForRow +} from './automation-list-row-identity' const repoMap = new Map([ [REPO_ID, { id: REPO_ID, displayName: 'orca', path: '/src/orca' } as Repo] @@ -177,4 +182,17 @@ describe('automation list row identity across hosts', () => { }) ).toEqual(['Desktop workspace', 'Runtime workspace']) }) + + it('resolves a folder workspace target inside each row authority', () => { + const rows = collidedListRows().rows + const folders = [ + makeFolderWorkspace({ id: 'fw-1', name: 'Desktop folder' }), + makeFolderWorkspace({ id: 'fw-1', name: 'Runtime folder', executionHostId: 'runtime:gpu' }) + ] + + expect( + rows.map((row) => automationFolderWorktreeForRow(row, folders, 'folder:fw-1')?.displayName) + ).toEqual(['Desktop folder', 'Runtime folder']) + expect(automationFolderWorktreeForRow(rows[0], folders, 'ws-1')).toBeUndefined() + }) }) diff --git a/src/renderer/src/components/automations/automation-list-row-identity.ts b/src/renderer/src/components/automations/automation-list-row-identity.ts index 577f323319f7..4b577d5dfe8d 100644 --- a/src/renderer/src/components/automations/automation-list-row-identity.ts +++ b/src/renderer/src/components/automations/automation-list-row-identity.ts @@ -19,11 +19,15 @@ import type { AutomationUsageSummary } from '../../../../shared/automation-usage import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import type { StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' import { + getConnectionExecutionHostId, getRepoExecutionHostId, getWorktreeExecutionHostId } from '../../../../shared/execution-host' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getFolderWorkspaceWorktree } from '../../store/slices/worktrees/listing/detected-worktree-meta' export type AutomationListRow = { /** Authority-qualified and incarnation-free; see the module note. */ @@ -80,15 +84,41 @@ export function automationRepoForRow( if (!authority) { return fallback.get(repoId) } - return repos.find((repo) => { - const host = getRepoExecutionHostId(repo) - return ( - repo.id === repoId && - (authority.kind === 'runtime' - ? host === `runtime:${encodeURIComponent(authority.environmentId)}` - : !host.startsWith('runtime:')) - ) - }) + return repos.find( + (repo) => repo.id === repoId && authorityOwnsHost(authority, getRepoExecutionHostId(repo)) + ) +} + +function authorityOwnsHost( + authority: StableAutomationCatalogRef['authority'], + hostId: string +): boolean { + return authority.kind === 'runtime' + ? hostId === `runtime:${encodeURIComponent(authority.environmentId)}` + : !hostId.startsWith('runtime:') +} + +/** Folder workspaces are not in `worktreesByRepo`; dispatch resolves `folder:` the same way. */ +export function automationFolderWorktreeForRow( + row: AutomationListRow, + folderWorkspaces: readonly FolderWorkspace[], + workspaceId: string | null | undefined = row.automation.workspaceId +): Worktree | undefined { + const scope = workspaceId ? parseWorkspaceKey(workspaceId) : null + if (scope?.type !== 'folder') { + return undefined + } + const authority = row.catalogRef?.authority + const matches = folderWorkspaces.filter( + (workspace) => + workspace.id === scope.folderWorkspaceId && + (!authority || + authorityOwnsHost( + authority, + workspace.executionHostId ?? getConnectionExecutionHostId(workspace.connectionId) + )) + ) + return matches.length === 1 ? getFolderWorkspaceWorktree(matches[0]) : undefined } export function automationWorktreeForRow( diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx b/src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx new file mode 100644 index 000000000000..b0f394858bc8 --- /dev/null +++ b/src/renderer/src/components/automations/use-automations-page-store-state.folder.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { makeFolderWorkspace } from '../sidebar/folder-workspace-composer-submit-fixtures' +import { makeAutomation } from './automations-page-fixtures' +import { unscopedAutomationListRows } from './automation-list-row-identity' +import { useAutomationsPageStoreState } from './use-automations-page-store-state' + +const initialState = useAppStore.getState() + +afterEach(() => { + useAppStore.setState(initialState, true) +}) + +describe('automation rows that target a folder workspace', () => { + it('find the folder workspace instead of reporting it gone', () => { + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'fw-1', name: 'App' })] + }) + const [row] = unscopedAutomationListRows([ + makeAutomation({ workspaceMode: 'existing', workspaceId: 'folder:fw-1' }) + ]) + const { result } = renderHook(() => useAutomationsPageStoreState()) + + expect(result.current.worktreeForRow(row, undefined)?.displayName).toBe('App') + }) +}) diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.ts b/src/renderer/src/components/automations/use-automations-page-store-state.ts index 1dbca5005c8d..b0737b064d3c 100644 --- a/src/renderer/src/components/automations/use-automations-page-store-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-store-state.ts @@ -6,6 +6,7 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { + automationFolderWorktreeForRow, automationRepoForRow, automationWorktreeForRow, type AutomationListRow @@ -18,6 +19,7 @@ export function useAutomationsPageStoreState() { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) @@ -52,8 +54,9 @@ export function useAutomationsPageStoreState() { repo: Repo | undefined, workspaceId: string | null | undefined = row.automation.workspaceId ): Worktree | undefined => + automationFolderWorktreeForRow(row, folderWorkspaces, workspaceId) ?? automationWorktreeForRow(row, worktreesByRepo, repo, worktreeMap, workspaceId), - [worktreeMap, worktreesByRepo] + [folderWorkspaces, worktreeMap, worktreesByRepo] ) const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents) const defaultAgent = diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts index 16a5728131be..491b38df33c1 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts @@ -78,6 +78,17 @@ export function folderWorkspaceMatchesHost( ) } +/** Stable synthetic row per folder workspace object, so selectors keep referential equality. */ +export function getFolderWorkspaceWorktree(folderWorkspace: FolderWorkspace): Worktree { + const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) + if (cached) { + return cached + } + const worktree = folderWorkspaceToWorktree(folderWorkspace) + folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) + return worktree +} + export function findKnownWorktreeById( state: Pick< AppState, @@ -105,16 +116,7 @@ export function findKnownWorktreeById( workspace.id === workspaceScope.folderWorkspaceId && (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) ) - if (!folderWorkspace) { - return undefined - } - const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) - if (cached) { - return cached - } - const worktree = folderWorkspaceToWorktree(folderWorkspace) - folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) - return worktree + return folderWorkspace ? getFolderWorkspaceWorktree(folderWorkspace) : undefined } const visible = executionHostId ? (findIndexedWorktreeOwnerForHost( From 80d34c3a55056f87b710485b525667d403b8d50c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 01:38:19 -0700 Subject: [PATCH 31/58] fix(browser): refuse web pages on the agent-browser CDP proxy (#27415) Co-authored-by: m4air --- .../browser/cdp-loopback-request-guard.ts | 15 +++ src/main/browser/cdp-ws-proxy-origin.test.ts | 94 +++++++++++++++++++ src/main/browser/cdp-ws-proxy.ts | 15 ++- 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/main/browser/cdp-loopback-request-guard.ts create mode 100644 src/main/browser/cdp-ws-proxy-origin.test.ts diff --git a/src/main/browser/cdp-loopback-request-guard.ts b/src/main/browser/cdp-loopback-request-guard.ts new file mode 100644 index 000000000000..1d449922c5b3 --- /dev/null +++ b/src/main/browser/cdp-loopback-request-guard.ts @@ -0,0 +1,15 @@ +import type { IncomingMessage } from 'node:http' + +/** + * Admits only same-machine automation clients to the CDP proxy, mirroring Chrome's own DevTools + * endpoint: browsers always send Origin on a websocket handshake (CDP clients don't), and a + * non-loopback Host means a DNS-rebound web page. Either would let any site drive the tab and + * read its cookies. + */ +export function isTrustedCdpLoopbackRequest(req: IncomingMessage, port: number): boolean { + if (req.headers.origin !== undefined) { + return false + } + const host = req.headers.host?.toLowerCase() + return host === `127.0.0.1:${port}` || host === `localhost:${port}` +} diff --git a/src/main/browser/cdp-ws-proxy-origin.test.ts b/src/main/browser/cdp-ws-proxy-origin.test.ts new file mode 100644 index 000000000000..26168ed06670 --- /dev/null +++ b/src/main/browser/cdp-ws-proxy-origin.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { request } from 'node:http' +import WebSocket from 'ws' +import { CdpWsProxy } from './cdp-ws-proxy' +import { + connect, + createMockWebContents, + getSendCommandMethods, + sendAndReceive, + type MockWebContents +} from './cdp-ws-proxy-test-harness' + +vi.mock('electron', () => ({ + webContents: { fromId: vi.fn() } +})) + +function rejectedUpgradeStatus(endpoint: string, headers: Record): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint, { headers }) + ws.on('open', () => { + ws.close() + reject(new Error('handshake was accepted')) + }) + ws.on('unexpected-response', (_req, res) => { + resolve(res.statusCode ?? 0) + ws.terminate() + }) + ws.on('error', () => {}) + }) +} + +function httpStatus(port: number, path: string, host: string): Promise { + return new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => { + res.resume() + resolve(res.statusCode ?? 0) + }) + req.on('error', reject) + req.end() + }) +} + +describe('CdpWsProxy loopback request guard', () => { + let mock: MockWebContents + let proxy: CdpWsProxy + let endpoint: string + + beforeEach(async () => { + mock = createMockWebContents() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements every WebContents member the proxy calls. + proxy = new CdpWsProxy(mock.webContents as never, () => () => {}) + endpoint = await proxy.start() + }) + + afterEach(async () => { + await proxy.stop() + }) + + it('refuses a websocket handshake from a web page origin', async () => { + await expect(rejectedUpgradeStatus(endpoint, { Origin: 'https://evil.example' })).resolves.toBe( + 403 + ) + }) + + it('refuses a websocket handshake carrying a rebound Host', async () => { + await expect( + rejectedUpgradeStatus(endpoint, { Host: `evil.example:${proxy.getPort()}` }) + ).resolves.toBe(403) + }) + + it('keeps the automation client connected when a web page tries to take over', async () => { + const client = await connect(endpoint) + await rejectedUpgradeStatus(endpoint, { Origin: 'https://evil.example' }) + + mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ ok: true }) + const response = await sendAndReceive(client, { id: 7, method: 'DOM.getDocument' }) + + expect(response.result).toEqual({ ok: true }) + expect(getSendCommandMethods(mock)).not.toContain('Network.getAllCookies') + client.close() + }) + + it('refuses discovery requests whose Host is not loopback', async () => { + await expect( + httpStatus(proxy.getPort(), '/json/version', `evil.example:${proxy.getPort()}`) + ).resolves.toBe(403) + }) + + it('still answers discovery requests addressed to loopback', async () => { + const port = proxy.getPort() + await expect(httpStatus(port, '/json/version', `127.0.0.1:${port}`)).resolves.toBe(200) + await expect(httpStatus(port, '/json/version', `localhost:${port}`)).resolves.toBe(200) + }) +}) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 4bc18eb41121..413e23301bdd 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -9,6 +9,7 @@ import { CdpPageNavigationCommands } from './cdp-page-navigation-commands' import { CdpDomFocusReplay } from './cdp-dom-focus-replay' import { CdpPageCaptureCommands } from './cdp-page-capture-commands' import type { CapturePaintHold } from './cdp-screenshot' +import { isTrustedCdpLoopbackRequest } from './cdp-loopback-request-guard' export class CdpWsProxy { private httpServer: Server | null = null @@ -54,8 +55,18 @@ export class CdpWsProxy { async start(): Promise { await this.debuggerChannel.attachDebugger() return new Promise((resolve, reject) => { - this.httpServer = createServer((req, res) => this.discovery.handleHttpRequest(req, res)) - this.wss = new WebSocketServer({ server: this.httpServer }) + this.httpServer = createServer((req, res) => { + if (!isTrustedCdpLoopbackRequest(req, this.port)) { + res.writeHead(403) + res.end() + return + } + this.discovery.handleHttpRequest(req, res) + }) + this.wss = new WebSocketServer({ + server: this.httpServer, + verifyClient: ({ req }, done) => done(isTrustedCdpLoopbackRequest(req, this.port), 403) + }) const failStart = (error: Error): void => { this.httpServer?.removeListener('error', onListenError) this.wss?.close() From e8b398141f212eb8a176fc69b325400fe9928498 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:40:22 -0700 Subject: [PATCH 32/58] Remove the unused runtime text-search spawn hook (#27418) #27396 deleted the write-only registry that consumed it; nothing sets the option now, and its doc comment implied a cancellation path that no longer exists. --- src/main/providers/local-filesystem-provider.ts | 6 ++---- src/main/runtime/runtime-file-command-target.ts | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/providers/local-filesystem-provider.ts b/src/main/providers/local-filesystem-provider.ts index 0e4532fa97d8..0219dd39f5c1 100644 --- a/src/main/providers/local-filesystem-provider.ts +++ b/src/main/providers/local-filesystem-provider.ts @@ -1,7 +1,6 @@ import { constants, copyFile, lstat, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { ChildProcessHandle } from '@orca/process-host/process-spec' import { readAuthorizedDocPreviewFile } from '../../shared/doc-preview-file-access' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' import type { resolveAuthorizedPath as ResolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -20,8 +19,6 @@ export type LocalFilesystemProviderOptions = { requireStore(): Store /** Injected: the auth layer reaches the host router, so importing it here would be a cycle. */ resolveAuthorizedPath: typeof ResolveAuthorizedPath - /** Registers a running text search for cancellation; returns its release. */ - onTextSearchSpawn?: (child: ChildProcessHandle) => () => void } /** @@ -149,7 +146,8 @@ export function createLocalFilesystemProvider( wslDistro, wslDistroForOutput: parseWslPath(rootPath)?.distro ?? wslDistro, signal, - onSpawn: options.onTextSearchSpawn ?? (() => () => undefined) + // Why: request abort owns cancellation; only the renderer IPC search keeps a by-id registry. + onSpawn: () => () => undefined }) }, listMarkdownDocuments: (rootPath) => diff --git a/src/main/runtime/runtime-file-command-target.ts b/src/main/runtime/runtime-file-command-target.ts index 12d43a23f585..21d22cb079b5 100644 --- a/src/main/runtime/runtime-file-command-target.ts +++ b/src/main/runtime/runtime-file-command-target.ts @@ -83,8 +83,7 @@ export function requireRuntimeFileProvider( } return route.createProvider({ requireStore: () => host.requireStore(), - resolveAuthorizedPath, - onTextSearchSpawn: host.onTextSearchSpawn + resolveAuthorizedPath }) } From 37277808b22c2fb3227ebd9756a4003d901a748b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 01:48:01 -0700 Subject: [PATCH 33/58] test(ssh): hostile-host cells state managed-orcad expectations only (#27419) Co-authored-by: m4air --- .github/workflows/ssh-hostile-hosts.yml | 28 ++- config/scripts/ci-shard-timings.json | 1 - src/main/ssh/ssh-hostile-host-cells.test.ts | 194 ++---------------- src/main/ssh/ssh-hostile-host-cells.ts | 182 +++------------- src/main/ssh/ssh-hostile-host-local-sshd.ts | 4 +- .../ssh/ssh-hostile-host-managed-orcad.ts | 51 ++--- .../ssh/ssh-hostile-host-observer.test.ts | 58 ------ src/main/ssh/ssh-hostile-host-observer.ts | 67 ------ src/main/ssh/ssh-hostile-host-test-fixture.ts | 33 +-- src/main/ssh/ssh-hostile-host-test-harness.ts | 5 +- src/main/ssh/ssh-hostile-hosts.docker.test.ts | 19 +- src/main/ssh/ssh-windows-host-cells.test.ts | 12 +- src/main/ssh/ssh-windows-host-cells.ts | 20 +- 13 files changed, 116 insertions(+), 558 deletions(-) delete mode 100644 src/main/ssh/ssh-hostile-host-observer.test.ts delete mode 100644 src/main/ssh/ssh-hostile-host-observer.ts diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index 368a97ee2879..1c1505e677e2 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -1,14 +1,15 @@ name: SSH hostile hosts -# Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH -# targets (old glibc, musl, no libstdc++, noexec home, no egress) and against each macOS runner's -# own loopback sshd, asserting which rung of the relay runtime ladder each lands on. Heavy (three -# slot builds plus seven images), so it runs only when the ladder, the runtime store, the relay +# Hostile-host matrix: the real client-side managed orcad deploy against container SSH targets +# (old glibc, musl, no libstdc++, noexec home, no egress) and against each macOS runner's own +# loopback sshd, asserting the runtime each host resolves and whether orcad activates there. +# Heavy (three slot builds plus seven images), so it runs only when the deploy, the runtime store # or the slot build changes, and on demand. on: pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: + # The managed deploy still runs on these relay-named install and runtime primitives. - 'src/main/ssh/ssh-relay-*' - 'src/main/ssh/*runtime*' - 'src/main/ssh/orcad-*' @@ -16,12 +17,10 @@ on: - 'src/main/ssh/ssh-remote-*' - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/sftp-*' - - 'src/wsl-guest/**' - 'src/shared/node-runtime-pin.ts' - 'src/shared/orcad-artifacts.ts' - 'config/scripts/build-orcad-*.mjs' - 'config/scripts/orcad-prebuild-*.mjs' - - 'config/scripts/build-relay.mjs' - 'config/scripts/verify-packaged-orcad-template.cjs' - 'config/patches/node-pty*' - '!src/**/*.test.ts' @@ -127,7 +126,7 @@ jobs: retention-days: 1 if-no-files-found: error - # Design D6 rung B: the CentOS 7 cell lands on the glibc 2.17 compat slot, built exactly as the + # The CentOS 7 cell lands on the glibc 2.17 compat slot, built exactly as the # headless-server compat lane builds it (see linux_glibc217_compat in node-server-tests.yml). glibc217_slot: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft != true }} @@ -190,19 +189,17 @@ jobs: run: | node config/scripts/merge-orcad-prebuilds.mjs "$RUNNER_TEMP"/hostile-host-prebuild-lanes/* pnpm build:orcad-prebuilds --require-slots linux-x64-glibc,linux-x64-musl,linux-x64-glibc217 - # The deploy materializes rung A, B and C addons from this template; only the x64 Linux - # slots exist here, so it is built for those two, and glibc217 is staged beside its base. - - name: Build the orcad template and relay - run: | - node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl - pnpm run build:relay + # Only the x64 Linux slots exist here, so the template is built for those two, and + # glibc217 is staged beside its base. + - name: Build the orcad template + run: node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl - name: Run the hostile-host matrix env: ORCA_RUN_SSH_HOSTILE_HOSTS: '1' ORCA_SSH_HOSTILE_HOST_CELLS: ${{ github.event.inputs.cells || '' }} run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts - # Design D6 on macOS: the runner is the SSH host. A user-level sshd on a loopback port logs in + # macOS: the runner is the SSH host. A user-level sshd on a loopback port logs in # as the runner user with PATH cut to the toolchain shims and /usr/bin:/bin, so Homebrew's node # and npm are unreachable; see ssh-hostile-host-local-sshd.ts for what SIP keeps in /usr/bin. macos_hosts: @@ -228,13 +225,12 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - - name: Build and smoke this runner's slot, the orcad template and relay + - name: Build and smoke this runner's slot and the orcad template run: | pnpm build:orcad-prebuilds pnpm build:orcad-prebuilds --require-slots ${{ matrix.target }} pnpm build:orcad-prebuilds --smoke node config/scripts/build-orcad-template.mjs --targets ${{ matrix.target }} - pnpm run build:relay - name: Run the macOS hostile-host cell env: ORCA_RUN_SSH_HOSTILE_HOSTS: '1' diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 38e19825aed6..1af4266ad64d 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5509,7 +5509,6 @@ "src/main/ssh/ssh-host-server-telemetry.test.ts": 228, "src/main/ssh/ssh-hostile-host-cells.test.ts": 137, "src/main/ssh/ssh-hostile-host-local-sshd.test.ts": 69, - "src/main/ssh/ssh-hostile-host-observer.test.ts": 79, "src/main/ssh/ssh-keyboard-interactive-wire.test.ts": 290, "src/main/ssh/ssh-keyboard-interactive.test.ts": 72, "src/main/ssh/ssh-known-hosts-source.test.ts": 128, diff --git a/src/main/ssh/ssh-hostile-host-cells.test.ts b/src/main/ssh/ssh-hostile-host-cells.test.ts index 498d569bac3c..eecc33bafab6 100644 --- a/src/main/ssh/ssh-hostile-host-cells.test.ts +++ b/src/main/ssh/ssh-hostile-host-cells.test.ts @@ -1,16 +1,14 @@ import { describe, expect, it } from 'vitest' import { canRunHostileHostCell, + forbiddenToolActions, forbiddenToolsFor, forbiddenToolShimScript, HOSTILE_HOST_CELLS, - hostileHostCellViolations, isLocalSshdCell, - parseForbiddenToolLog, selectHostileHostCells, type DockerHostileHostCell, - type HostileHostCell, - type HostileHostObservation + type HostileHostCell } from './ssh-hostile-host-cells' import { hostileHostDockerfile } from './ssh-hostile-host-test-fixture' @@ -36,52 +34,27 @@ function cell(id: string): HostileHostCell { return found } -const launched: HostileHostObservation = { - settledRung: 'A', - target: 'linux-x64-glibc', - unavailableReason: null, - deployError: null, - refusals: [], - forbiddenToolCalls: [] -} - describe('hostile-host cells', () => { - it('covers each design D6 ladder outcome the matrix is meant to prove', () => { - expect(HOSTILE_HOST_CELLS.map((c) => c.id)).toEqual([ - 'debian10-glibc228', - 'almalinux8-glibc228', - 'alpine-musl', - 'alpine-musl-no-libstdcxx', - 'ubuntu2204-node20-noexec-home', - 'centos7-glibc217', - 'debian10-no-egress', - 'macos-arm64-local-sshd', - 'macos-x64-local-sshd' + it('states a managed orcad expectation for every host the matrix runs', () => { + expect(HOSTILE_HOST_CELLS.map((c) => [c.id, c.managed.runtime, c.managed.outcome])).toEqual([ + ['debian10-glibc228', 'linux-x64-glibc', 'activated'], + ['almalinux8-glibc228', 'linux-x64-glibc', 'activated'], + ['alpine-musl', 'linux-x64-musl', 'activated'], + ['alpine-musl-no-libstdcxx', 'linux-x64-musl', 'unavailable'], + ['ubuntu2204-noexec-home', 'linux-x64-glibc', 'unavailable'], + ['centos7-glibc217', 'linux-x64-glibc217', 'activated'], + ['debian10-no-egress', 'linux-x64-glibc', 'activated'], + ['macos-arm64-local-sshd', 'darwin-arm64', 'activated'], + ['macos-x64-local-sshd', 'darwin-x64', 'activated'] ]) - expect(new Set(HOSTILE_HOST_CELLS.map((c) => c.expect.outcome))).toEqual( - new Set(['launched', 'unavailable']) - ) - expect( - new Set( - HOSTILE_HOST_CELLS.flatMap((c) => (c.expect.outcome === 'launched' ? [c.expect.rung] : [])) - ) - ).toEqual(new Set(['A', 'B'])) }) - it('deploys managed orcad on CentOS 7 with the compat runtime its relay rung B runs', () => { - const centos = cell('centos7-glibc217') - expect(centos.managed?.runtime).toBe('linux-x64-glibc217') - expect(centos.expect).toMatchObject({ runtime: centos.managed?.runtime }) - }) - - it('pins every base image by digest and installs no compiler or host Node for rung A', () => { - for (const { dockerfile, expect: expectation } of DOCKER_CELLS) { + it('pins every base image by digest and installs no compiler or host Node', () => { + for (const { dockerfile } of DOCKER_CELLS) { for (const from of dockerfile.filter((line) => line.startsWith('FROM '))) { expect(from).toMatch(/@sha256:[0-9a-f]{64}\b/) } - if (expectation.outcome === 'launched') { - expect(dockerfile.join('\n')).not.toMatch(/\b(?:gcc|g\+\+|build-essential|nodejs|npm)\b/) - } + expect(dockerfile.join('\n')).not.toMatch(/\b(?:gcc|g\+\+|build-essential|nodejs|npm)\b/) } }) @@ -125,13 +98,7 @@ describe('hostile-host cells', () => { ).toBe(true) }) - it('expects each macOS runner on rung A with its own darwin slot, and forbids xattr there', () => { - expect(cell('macos-arm64-local-sshd').expect).toEqual({ - outcome: 'launched', - rung: 'A', - target: 'darwin-arm64' - }) - expect(cell('macos-x64-local-sshd').expect).toMatchObject({ target: 'darwin-x64' }) + it('forbids xattr only on the macOS runners', () => { expect(forbiddenToolsFor(cell('macos-arm64-local-sshd'))).toContain('xattr') expect(forbiddenToolsFor(cell('debian10-glibc228'))).not.toContain('xattr') }) @@ -142,129 +109,10 @@ describe('hostile-host cells', () => { ) }) - it('reads one call per line of the shim log', () => { - expect(parseForbiddenToolLog('npm install\n\ngcc -v\n')).toEqual(['npm install', 'gcc -v']) - }) -}) - -describe('hostileHostCellViolations', () => { - it('accepts rung A with no refusals and no toolchain', () => { - expect(hostileHostCellViolations(cell('debian10-glibc228'), launched)).toEqual([]) - }) - - it('flags a rung A host that invoked npm or landed on the wrong slot', () => { - expect( - hostileHostCellViolations(cell('alpine-musl'), { - ...launched, - forbiddenToolCalls: ['npm install'] - }) - ).toEqual(['toolchain invoked: npm install', 'target linux-x64-glibc, expected linux-x64-musl']) - }) - - it('flags a launched host that stepped down the ladder', () => { - expect( - hostileHostCellViolations(cell('almalinux8-glibc228'), { - ...launched, - settledRung: 'C', - refusals: [ - { step: 'A', reason: 'missing_lib' }, - { step: 'B', reason: 'runtime_unavailable' } - ] - }) - ).toEqual([ - 'refusals A:missing_lib > B:runtime_unavailable, expected none', - 'settled on C, expected A' - ]) - }) - - it('requires the classified rung D reason and refusal chain', () => { - const noexec = cell('ubuntu2204-node20-noexec-home') - const observed: HostileHostObservation = { - settledRung: 'D', - target: 'linux-x64-glibc', - unavailableReason: 'home_noexec', - deployError: 'home directory is mounted noexec', - refusals: [ - { step: 'A', reason: 'noexec' }, - { step: 'legacy', reason: 'host_node_missing' } - ], - forbiddenToolCalls: [] - } - expect(hostileHostCellViolations(noexec, observed)).toEqual([]) - // The fallback's read-only npm probe is allowed; an install is not. - expect( - hostileHostCellViolations(noexec, { ...observed, forbiddenToolCalls: ['npm --version'] }) - ).toEqual([]) - expect( - hostileHostCellViolations(noexec, { - ...observed, - forbiddenToolCalls: ['npm --version', 'npm ci --omit=dev'] - }) - ).toEqual(['toolchain invoked: npm ci --omit=dev']) - expect( - hostileHostCellViolations(noexec, { - ...observed, - unavailableReason: 'no_runtime', - refusals: [ - { step: 'A', reason: 'noexec' }, - { step: 'C', reason: 'noexec' } - ] - }) - ).toEqual([ - 'refusals A:noexec > C:noexec, expected A:noexec > legacy:host_node_missing', - 'rung D reason no_runtime, expected home_noexec' - ]) - }) - - it('expects a glibc 2.17 host to launch on the rung B compat runtime', () => { - const centos = cell('centos7-glibc217') - const observed: HostileHostObservation = { - ...launched, - settledRung: 'B', - refusals: [{ step: 'A', reason: 'libc_floor' }] - } - expect(hostileHostCellViolations(centos, observed)).toEqual([]) - expect( - hostileHostCellViolations(centos, { - ...observed, - settledRung: null, - deployError: 'Node.js was not found on the remote host', - refusals: [ - { step: 'A', reason: 'libc_floor' }, - { step: 'B', reason: 'artifacts_unavailable' }, - { step: 'C', reason: 'libc_floor' } - ], - forbiddenToolCalls: ['npm --version', 'npm install', 'make BUILDTYPE=Release -C build'] - }) - ).toEqual([ - 'refusals A:libc_floor > B:artifacts_unavailable > C:libc_floor, expected A:libc_floor', - 'toolchain invoked: npm install; make BUILDTYPE=Release -C build', - 'deploy failed: Node.js was not found on the remote host', - 'settled on nothing, expected B' - ]) - }) - - it('judges an opted-out host only on the ladder never running', () => { - const optOut = { id: 'opt-out', expect: { outcome: 'legacy_opt_out' } } as const - const observed: HostileHostObservation = { - settledRung: null, - target: null, - unavailableReason: null, - deployError: 'Node.js was not found on the remote host', - refusals: [], - forbiddenToolCalls: ['npm'] - } - expect(hostileHostCellViolations(optOut, observed)).toEqual([]) - expect(hostileHostCellViolations(optOut, { ...observed, deployError: null })).toEqual([]) - expect( - hostileHostCellViolations(optOut, { - ...observed, - settledRung: 'A', - refusals: [{ step: 'A', reason: 'noexec' }] - }) - ).toEqual([ - 'refusals A:noexec, expected none', - 'settled on A, expected the ladder never to run' + it('reports logged installs and compiles but not version probes', () => { + expect(forbiddenToolActions('npm install\n\ngcc -v\nnpm --version\nmake all\n')).toEqual([ + 'npm install', + 'make all' ]) }) }) diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index 94874f7fcdc1..7efc9f1a2e41 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -1,15 +1,12 @@ /** - * The design D5/D6 hostile-host matrix: each cell is an SSH target (a container, or the runner's - * own loopback sshd) and the place the relay runtime ladder must land there. - * `ssh-hostile-hosts.docker.test.ts` drives the real client-side deploy against each one; - * `.github/workflows/ssh-hostile-hosts.yml` runs it. + * The hostile-host matrix: each cell is an SSH target (a container, or the runner's own loopback + * sshd) and what managed orcad must do there. `ssh-hostile-hosts.docker.test.ts` drives the real + * client-side deploy against each one; `.github/workflows/ssh-hostile-hosts.yml` runs it. */ -import type { NodeRuntimeTarget, ServerTarget } from '../../shared/node-runtime-pin' -import type { SshRemoteRuntimeRung } from '../../shared/ssh-types' -import type { RelayRuntimeFallbackReason } from './ssh-relay-pinned-node' -import type { RelayRuntimeStep, RemoteRuntimeUnavailableReason } from './ssh-relay-runtime-ladder' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' +import type { OrcadHostUnavailableReason } from './orcad-host-unavailable' -/** Every shimmed toolchain command appends its name here; rungs A and C must leave it empty. */ +/** Every shimmed toolchain command appends its name here; a managed deploy must leave it empty. */ export const FORBIDDEN_TOOL_LOG = '/tmp/orca-forbidden-tool-calls.log' export const FORBIDDEN_TOOLS = ['npm', 'npx', 'node-gyp', 'gcc', 'g++', 'cc', 'c++', 'make'] @@ -20,39 +17,17 @@ export function forbiddenToolShimScript(logPath: string): string { ) } -export type RungRefusal = { step: RelayRuntimeStep; reason: RelayRuntimeFallbackReason } - -export type HostileHostExpectation = - /** A pinned rung ran: the terminal echoes, a second connect reuses the runtime, GC keeps it. */ - | { - outcome: 'launched' - rung: 'A' | 'B' - /** The host's target; `runtime` names the compat runtime rung B ran on instead. */ - target: ServerTarget - runtime?: NodeRuntimeTarget - refusals?: readonly RungRefusal[] - } - /** Rung D: nothing may run, and the connect fails with the classified reason. */ - | { - outcome: 'unavailable' - reason: RemoteRuntimeUnavailableReason - refusals: readonly RungRefusal[] - } - /** The host opted out: the ladder never runs and nothing enters the pinned runtime store. */ - | { outcome: 'legacy_opt_out' } - /** What any hostile-host driver needs, whatever provisions the host. */ export type HostileHostCellCore = { id: string - expect: HostileHostExpectation - /** Also deploy managed orcad on a fresh host, on this runtime target. */ - managed?: ManagedOrcadExpectation + /** Managed orcad on a fresh host: the runtime target it resolves, and what the deploy does there. */ + managed: ManagedOrcadExpectation } export type ManagedOrcadExpectation = | { outcome: 'activated'; runtime: NodeRuntimeTarget } - /** The candidate is refused with this deferral code, and the relay that follows settles on `relayRung`. */ - | { outcome: 'refused'; runtime: NodeRuntimeTarget; code: string; relayRung: 'A' | 'B' } + /** The deploy throws an error the connect classifies as this host being unable to run orcad. */ + | { outcome: 'unavailable'; runtime: NodeRuntimeTarget; reason: OrcadHostUnavailableReason } export type DockerHostileHostCell = HostileHostCellCore & { host?: 'docker' @@ -101,8 +76,6 @@ const ALPINE_3_20 = const UBUNTU_22_04 = 'ubuntu:22.04@sha256:b8b6ee6aa931ecd9d0d952abc34dc0e5f7c6a30c6bb71b079fe399fde0329c02' const CENTOS_7 = 'centos:7@sha256:be65f488b7764ad3638f236b7b515b3678369a5124c47b8d32916d6487418ea4' -const NODE_20 = - 'node:20-bookworm-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0' // Why the archive: buster left deb.debian.org; its packages live on only at archive.debian.org. const DEBIAN_10_LINES = [ @@ -119,7 +92,7 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ { id: 'debian10-glibc228', dockerfile: DEBIAN_10_LINES, - expect: { outcome: 'launched', rung: 'A', target: 'linux-x64-glibc' } + managed: { outcome: 'activated', runtime: 'linux-x64-glibc' } }, { id: 'almalinux8-glibc228', @@ -127,53 +100,33 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ `FROM ${ALMALINUX_8}`, 'RUN dnf install -y openssh-server procps-ng tar gzip && dnf clean all' ], - expect: { outcome: 'launched', rung: 'A', target: 'linux-x64-glibc' } + managed: { outcome: 'activated', runtime: 'linux-x64-glibc' } }, { id: 'alpine-musl', dockerfile: [`FROM ${ALPINE_3_20}`, `${ALPINE_SSHD} libgcc libstdc++`], - expect: { outcome: 'launched', rung: 'A', target: 'linux-x64-musl' } + managed: { outcome: 'activated', runtime: 'linux-x64-musl' } }, { - // The musl Node links libstdc++, so its self-test proves the missing library; B has no - // compat runtime yet, C finds no host Node, and the host-Node fallback proves it has none. + // The musl Node links libstdc++, so its self-test fails before orcad can start. id: 'alpine-musl-no-libstdcxx', dockerfile: [`FROM ${ALPINE_3_20}`, ALPINE_SSHD], - expect: { - outcome: 'unavailable', - reason: 'no_runtime', - refusals: [ - { step: 'A', reason: 'missing_lib' }, - { step: 'B', reason: 'runtime_unavailable' }, - { step: 'C', reason: 'host_node_missing' }, - { step: 'legacy', reason: 'host_node_missing' } - ] - } + managed: { outcome: 'unavailable', runtime: 'linux-x64-musl', reason: 'runtime_self_test' } }, { - // A proved noexec skips B and C (same tree) for the host-Node fallback, which finds Node 20 - // without npm; that answered "no Node" is the only way to D. - id: 'ubuntu2204-node20-noexec-home', + // The runtime lands under the noexec home, so its self-test cannot execute it. + id: 'ubuntu2204-noexec-home', dockerfile: [ - `FROM ${NODE_20} AS host-node`, `FROM ${UBUNTU_22_04}`, 'RUN apt-get update' + ' && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server procps' + - ' && rm -rf /var/lib/apt/lists/*', - 'COPY --from=host-node /usr/local/bin/node /usr/local/bin/node' + ' && rm -rf /var/lib/apt/lists/*' ], homeNoexec: true, - expect: { - outcome: 'unavailable', - reason: 'home_noexec', - refusals: [ - { step: 'A', reason: 'noexec' }, - { step: 'legacy', reason: 'host_node_missing' } - ] - } + managed: { outcome: 'unavailable', runtime: 'linux-x64-glibc', reason: 'runtime_self_test' } }, { - // glibc 2.17 is below the pinned Node's floor, so rung B runs the glibc 2.17 compat runtime. + // glibc 2.17 is below the pinned Node's floor, so orcad runs on the glibc 2.17 compat runtime. id: 'centos7-glibc217', dockerfile: [ `FROM ${CENTOS_7}`, @@ -181,36 +134,28 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ "RUN sed -i -e 's/^mirrorlist=/#mirrorlist=/' -e 's|^#\\?baseurl=http://mirror.centos.org/centos/$releasever|baseurl=http://vault.centos.org/7.9.2009|' /etc/yum.repos.d/CentOS-*.repo" + ' && yum install -y openssh-server procps-ng && yum clean all' ], - expect: { - outcome: 'launched', - rung: 'B', - target: 'linux-x64-glibc', - runtime: 'linux-x64-glibc217', - refusals: [{ step: 'A', reason: 'libc_floor' }] - }, - // Managed orcad runs on the same compat runtime and slot, so an empty CentOS 7 host is managed. managed: { outcome: 'activated', runtime: 'linux-x64-glibc217' } }, { - // The client uploads the runtime over SSH, so a host that cannot reach nodejs.org still runs A. + // The client uploads the runtime over SSH, so a host that cannot reach nodejs.org still runs orcad. id: 'debian10-no-egress', dockerfile: DEBIAN_10_LINES, noEgress: true, - expect: { outcome: 'launched', rung: 'A', target: 'linux-x64-glibc' } + managed: { outcome: 'activated', runtime: 'linux-x64-glibc' } }, { id: 'macos-arm64-local-sshd', host: 'local-sshd', runsOn: { platform: 'darwin', arch: 'arm64' }, extraForbiddenTools: MACOS_FORBIDDEN_TOOLS, - expect: { outcome: 'launched', rung: 'A', target: 'darwin-arm64' } + managed: { outcome: 'activated', runtime: 'darwin-arm64' } }, { id: 'macos-x64-local-sshd', host: 'local-sshd', runsOn: { platform: 'darwin', arch: 'x64' }, extraForbiddenTools: MACOS_FORBIDDEN_TOOLS, - expect: { outcome: 'launched', rung: 'A', target: 'darwin-x64' } + managed: { outcome: 'activated', runtime: 'darwin-x64' } } ] @@ -256,81 +201,10 @@ export function selectHostileHostCells( ) } -export type HostileHostObservation = { - /** The rung the ladder settled on, or null when it never settled. */ - settledRung: SshRemoteRuntimeRung | null - /** The server target of a launched relay, when the ladder resolved one. */ - target: ServerTarget | null - /** Set when the deploy failed with the rung D error. */ - unavailableReason: RemoteRuntimeUnavailableReason | null - deployError: string | null - refusals: readonly RungRefusal[] - forbiddenToolCalls: readonly string[] -} - -function describeRefusals(refusals: readonly RungRefusal[]): string { - return refusals.map(({ step, reason }) => `${step}:${reason}`).join(' > ') || 'none' -} - -/** Every way `observed` departs from the cell's expectation; empty when the cell holds. */ -export function hostileHostCellViolations( - cell: HostileHostCellCore, - observed: HostileHostObservation -): string[] { - const { expect } = cell - const violations: string[] = [] - const expectedRefusals = expect.outcome === 'legacy_opt_out' ? [] : (expect.refusals ?? []) - if (describeRefusals(observed.refusals) !== describeRefusals(expectedRefusals)) { - violations.push( - `refusals ${describeRefusals(observed.refusals)}, expected ${describeRefusals(expectedRefusals)}` - ) - } - // Why every ladder outcome: nothing may install or compile. A version probe is read-only; the - // host-Node fallback asks `npm --version` before it settles D on a host with no usable npm. - const toolchainActions = observed.forbiddenToolCalls.filter((call) => !isReadOnlyToolProbe(call)) - if (expect.outcome !== 'legacy_opt_out' && toolchainActions.length > 0) { - violations.push(`toolchain invoked: ${toolchainActions.join('; ')}`) - } - switch (expect.outcome) { - case 'launched': - if (observed.deployError) { - violations.push(`deploy failed: ${observed.deployError}`) - } - if (observed.settledRung !== expect.rung) { - violations.push(`settled on ${observed.settledRung ?? 'nothing'}, expected ${expect.rung}`) - } - if (observed.target !== expect.target) { - violations.push(`target ${observed.target ?? 'unresolved'}, expected ${expect.target}`) - } - break - case 'unavailable': - if (observed.settledRung !== 'D') { - violations.push(`settled on ${observed.settledRung ?? 'nothing'}, expected D`) - } - if (observed.unavailableReason !== expect.reason) { - violations.push( - `rung D reason ${observed.unavailableReason ?? 'none'}, expected ${expect.reason}` - ) - } - break - case 'legacy_opt_out': - // The host-Node path's own verdict depends on the host's Node, so only the ladder is judged. - if (observed.settledRung !== null) { - violations.push(`settled on ${observed.settledRung}, expected the ladder never to run`) - } - break - } - return violations -} - -/** A shim call that only asks a tool its version, as ` --version` or ` -v`. */ -function isReadOnlyToolProbe(call: string): boolean { - return /^\S+ (?:--version|-v)$/.test(call.trim()) -} - -export function parseForbiddenToolLog(contents: string): string[] { - return contents +/** Logged toolchain calls that install or compile; a bare `--version`/`-v` probe is read-only. */ +export function forbiddenToolActions(log: string): string[] { + return log .split('\n') .map((line) => line.trim()) - .filter(Boolean) + .filter((call) => call && !/^\S+ (?:--version|-v)$/.test(call)) } diff --git a/src/main/ssh/ssh-hostile-host-local-sshd.ts b/src/main/ssh/ssh-hostile-host-local-sshd.ts index d2fc3dbd466e..9b8b77542fd0 100644 --- a/src/main/ssh/ssh-hostile-host-local-sshd.ts +++ b/src/main/ssh/ssh-hostile-host-local-sshd.ts @@ -95,7 +95,7 @@ function freeLoopbackPort(): Promise { export async function startLocalSshdTarget( cell: LocalSshdHostileHostCell ): Promise { - // Why /tmp: the remote home holds relay sockets, and macOS caps sun_path at 104 bytes. + // Why /tmp: the remote home holds orcad's daemon socket, and macOS caps sun_path at 104 bytes. const tempDir = await mkdtemp(join(await realpath('/tmp'), 'orca-hh-')) try { const home = join(tempDir, 'home') @@ -176,7 +176,7 @@ export async function stopLocalSshdTarget(target: LocalSshdHostileHostTarget): P target.sshd.kill('SIGTERM') await exited } - // Why: the relay daemonizes out of the sshd session; it runs from the temp home, so its argv names it. + // Why: orcad daemonizes out of the sshd session; it runs from the temp home, so its argv names it. await runProcess({ program: 'pkill', args: ['-TERM', '-f', target.tempDir], timeoutMs: 10_000 }) if (!(await waitForNoProcessMatching(target.tempDir, 10_000))) { await runProcess({ program: 'pkill', args: ['-KILL', '-f', target.tempDir], timeoutMs: 10_000 }) diff --git a/src/main/ssh/ssh-hostile-host-managed-orcad.ts b/src/main/ssh/ssh-hostile-host-managed-orcad.ts index f4ed9de73ebe..528dd9707da8 100644 --- a/src/main/ssh/ssh-hostile-host-managed-orcad.ts +++ b/src/main/ssh/ssh-hostile-host-managed-orcad.ts @@ -1,8 +1,7 @@ /** - * The managed-orcad half of a hostile-host cell: on a fresh host, resolve the context, deploy - * orcad, and prove it picked the cell's runtime and uploaded no other. An activated server must - * be live; a refused one must be classified as unable to run, which the connect reports as an - * unsupported host. + * One hostile-host cell: on a fresh host, resolve the context, deploy orcad, and prove it picked + * the cell's runtime, uploaded no other and ran no toolchain. An activated server must be live; an + * unavailable host must fail with the classified reason the connect reports. */ import { posix } from 'node:path' import { expect } from 'vitest' @@ -19,9 +18,14 @@ import { deployOrcad } from './orcad-remote-deploy' import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' import { execOrcadRemote } from './orcad-remote-runtime-control' import { decommissionRemoteOrcad } from './orcad-remote-decommission' -import type { HostileHostCellCore, ManagedOrcadExpectation } from './ssh-hostile-host-cells' +import { shellEscape } from './ssh-connection-utils' +import { + forbiddenToolActions, + type HostileHostCellCore, + type ManagedOrcadExpectation +} from './ssh-hostile-host-cells' import { connectHostileHost } from './ssh-hostile-host-test-harness' -import { hostExecStatus, type HostileHostTarget } from './ssh-hostile-host-test-fixture' +import { hostExec, hostExecStatus, type HostileHostTarget } from './ssh-hostile-host-test-fixture' const EMPTY_CENSUS = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } @@ -31,32 +35,33 @@ export async function proveManagedOrcadCell( sshTarget: SshTarget ): Promise { const expected = cell.managed - if (!expected) { - throw new Error(`${cell.id} names no managed expectation`) - } const conn = await connectHostileHost(sshTarget) try { const context = await resolveOrcadRemoteContext(sshTarget, conn) expect(context.serverTarget).toBe(expected.runtime) const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT) - const deployed = await deployOrcad({ - ...slot, - target: context.serverTarget, - census: EMPTY_CENSUS - }) - const slotDir = orcadSlotDir(slot, deployed.fullVersion) - await assertOnlyRuntimeUploaded(target, slotDir, expected) - if (expected.outcome === 'refused') { + const deploy = deployOrcad({ ...slot, target: context.serverTarget, census: EMPTY_CENSUS }) + if (expected.outcome === 'unavailable') { + const failure = await deploy.then( + (deployed) => new Error(`deploy did not fail: ${JSON.stringify(deployed)}`), + (error: unknown) => error + ) + expect(classifyOrcadHostUnavailable(failure), String(failure)).toBe(expected.reason) + } else { + const deployed = await deploy + const slotDir = orcadSlotDir(slot, deployed.fullVersion) + await assertOnlyRuntimeUploaded(target, slotDir, expected) // The message carries the full reason, which toMatchObject's diff omits. expect(deployed, JSON.stringify(deployed)).toMatchObject({ - outcome: 'installed-not-activated', - code: expected.code + outcome: 'installed-and-activated' }) - expect(classifyOrcadHostUnavailable(deployed)).toBe('native_preflight') - return + await assertLiveThenDecommissioned(slot, slotDir, deployed.fullVersion) } - expect(deployed, JSON.stringify(deployed)).toMatchObject({ outcome: 'installed-and-activated' }) - await assertLiveThenDecommissioned(slot, slotDir, deployed.fullVersion) + const toolLog = await hostExec( + target, + `cat ${shellEscape(target.forbiddenToolLog)} 2>/dev/null || true` + ) + expect(forbiddenToolActions(toolLog)).toEqual([]) } finally { await conn.disconnect().catch(() => {}) } diff --git a/src/main/ssh/ssh-hostile-host-observer.test.ts b/src/main/ssh/ssh-hostile-host-observer.test.ts deleted file mode 100644 index 4bc91b611ffd..000000000000 --- a/src/main/ssh/ssh-hostile-host-observer.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { mkdtempSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { localHostObserver } from './ssh-hostile-host-observer' - -describe('local hostile-host observer', () => { - let root = '' - - afterEach(() => { - if (root) { - removeTreeSync(root) - root = '' - } - }) - - it('reads an absent forbidden-tool log as no calls', async () => { - root = mkdtempSync(join(tmpdir(), 'orca-observer-')) - const log = join(root, 'calls.log') - const observer = localHostObserver(log) - expect(await observer.readForbiddenToolLog()).toBe('') - writeFileSync(log, 'npm\r\n') - expect(await observer.readForbiddenToolLog()).toBe('npm\r\n') - }) - - it('plants idle runtimes whose marker age GC orders by', async () => { - root = mkdtempSync(join(tmpdir(), 'orca-observer-')) - const observer = localHostObserver(join(root, 'calls.log')) - // Forward slashes, as a Windows host's remote paths are spelled. - const store = `${root.replaceAll('\\', '/')}/runtimes` - await observer.plantIdleRuntime(store, 'node-old', 'old') - await observer.plantIdleRuntime(store, 'node-new', 'new') - const oldMarker = statSync(join(root, 'runtimes', 'node-old', '.verified')) - const newMarker = statSync(join(root, 'runtimes', 'node-new', '.verified')) - expect(oldMarker.mtime.getUTCFullYear()).toBe(2000) - expect(newMarker.mtimeMs).toBeGreaterThan(oldMarker.mtimeMs) - expect(await observer.exists(`${store}/node-old`)).toBe(true) - expect(await observer.isFile(`${store}/node-old`)).toBe(false) - expect(await observer.isFile(`${store}/node-old/.verified`)).toBe(true) - expect(await observer.exists(`${store}/node-gone`)).toBe(false) - }) - - it('hashes a file and stamps it so a rewrite shows', async () => { - root = mkdtempSync(join(tmpdir(), 'orca-observer-')) - const file = join(root, 'node.exe') - writeFileSync(file, 'abc') - const observer = localHostObserver(join(root, 'calls.log')) - expect(await observer.fileSha256(file)).toBe( - 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' - ) - const before = await observer.fileStamp(file) - expect(await observer.fileStamp(file)).toBe(before) - await new Promise((resolve) => setTimeout(resolve, 20)) - writeFileSync(file, 'abcd') - expect(await observer.fileStamp(file)).not.toBe(before) - }) -}) diff --git a/src/main/ssh/ssh-hostile-host-observer.ts b/src/main/ssh/ssh-hostile-host-observer.ts deleted file mode 100644 index a72f0e3aaf1b..000000000000 --- a/src/main/ssh/ssh-hostile-host-observer.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * How a hostile-host driver inspects the SSH host outside SSH: `docker exec` for container cells, - * the local filesystem for a loopback Windows host the test runner administers. - */ -import { createHash } from 'node:crypto' -import { mkdir, readFile, stat, utimes, writeFile } from 'node:fs/promises' -import { join } from 'node:path' - -/** `old` is far older than any real runtime, so GC must collect it; `new` is newer than the live one. */ -export type IdleRuntimeAge = 'old' | 'new' - -export type HostileHostObserver = { - /** Newline-separated forbidden-tool invocations; empty when none ran. */ - readForbiddenToolLog: () => Promise - /** A verified-looking runtime directory that no reference or process holds. */ - plantIdleRuntime: (storeDir: string, name: string, age: IdleRuntimeAge) => Promise - exists: (path: string) => Promise - isFile: (path: string) => Promise - /** Changes whenever the file is replaced or rewritten. */ - fileStamp: (path: string) => Promise - fileSha256: (path: string) => Promise -} - -const OLD_RUNTIME_TIME = new Date('2000-01-01T00:00:00Z') - -/** For a host on this machine; remote paths use `/`, which Node accepts on Windows too. */ -export function localHostObserver(forbiddenToolLog: string): HostileHostObserver { - return { - readForbiddenToolLog: async () => { - try { - return await readFile(forbiddenToolLog, 'utf8') - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return '' - } - throw error - } - }, - plantIdleRuntime: async (storeDir, name, age) => { - const dir = join(storeDir, name) - await mkdir(dir, { recursive: true }) - const marker = join(dir, '.verified') - await writeFile(marker, '') - if (age === 'old') { - await utimes(marker, OLD_RUNTIME_TIME, OLD_RUNTIME_TIME) - } - }, - exists: (path) => - stat(path).then( - () => true, - () => false - ), - isFile: (path) => - stat(path).then( - (info) => info.isFile(), - () => false - ), - fileStamp: async (path) => { - const info = await stat(path, { bigint: true }) - return `${info.ino}:${info.mtimeNs}` - }, - fileSha256: async (path) => - createHash('sha256') - .update(await readFile(path)) - .digest('hex') - } -} diff --git a/src/main/ssh/ssh-hostile-host-test-fixture.ts b/src/main/ssh/ssh-hostile-host-test-fixture.ts index 3336e79ae909..4d09aaf777d4 100644 --- a/src/main/ssh/ssh-hostile-host-test-fixture.ts +++ b/src/main/ssh/ssh-hostile-host-test-fixture.ts @@ -1,11 +1,10 @@ -/** Starts one hostile-host cell as an SSH target for the relay deploy: a Docker sshd or a loopback one. */ +/** Starts one hostile-host cell as an SSH target for the orcad deploy: a Docker sshd or a loopback one. */ import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { runProcess } from '@orca/process-host' import type { SshTarget } from '../../shared/ssh-types' -import { shellEscape } from './ssh-connection-utils' import { FORBIDDEN_TOOL_LOG, FORBIDDEN_TOOLS, @@ -23,7 +22,6 @@ import { stopLocalSshdTarget, type LocalSshdHostileHostTarget } from './ssh-hostile-host-local-sshd' -import { localHostObserver, type HostileHostObserver } from './ssh-hostile-host-observer' export type DockerHostileHostTarget = { kind: 'docker' @@ -263,34 +261,6 @@ export async function hostExecStatus(target: HostileHostTarget, command: string) return result.code ?? -1 } -/** Observes the container as root through `docker exec`, never through the SSH session under test. */ -function dockerHostObserver(target: DockerHostileHostTarget): HostileHostObserver { - return { - readForbiddenToolLog: () => - hostExec(target, `cat ${shellEscape(target.forbiddenToolLog)} 2>/dev/null || true`), - plantIdleRuntime: async (storeDir, name, age) => { - const dir = `${storeDir}/${name}` - const stamp = age === 'old' ? '-t 200001010000 ' : '' - await hostExec( - target, - `mkdir -p ${shellEscape(`${dir}/bin`)} && touch ${stamp}${shellEscape(`${dir}/.verified`)}` - ) - }, - exists: async (path) => (await hostExecStatus(target, `test -e ${shellEscape(path)}`)) === 0, - isFile: async (path) => (await hostExecStatus(target, `test -f ${shellEscape(path)}`)) === 0, - fileStamp: (path) => hostExec(target, `stat -c '%i:%Y' ${shellEscape(path)}`), - fileSha256: async (path) => - (await hostExec(target, `sha256sum ${shellEscape(path)}`)).split(/\s+/)[0] - } -} - -/** A loopback sshd's host is this machine, so its files are read directly. */ -export function hostileHostObserver(target: HostileHostTarget): HostileHostObserver { - return target.kind === 'docker' - ? dockerHostObserver(target) - : localHostObserver(target.forbiddenToolLog) -} - export function hostileHostSshTarget(target: HostileHostTarget): SshTarget { return { id: `hostile-${target.cell.id}-${randomUUID()}`, @@ -300,7 +270,6 @@ export function hostileHostSshTarget(target: HostileHostTarget): SshTarget { port: target.port, username: target.username, identityFile: target.identityFile, - // No remoteRuntime: the matrix proves the ladder a host gets by default. identitiesOnly: true } } diff --git a/src/main/ssh/ssh-hostile-host-test-harness.ts b/src/main/ssh/ssh-hostile-host-test-harness.ts index c301594c9867..c9eb0babf8be 100644 --- a/src/main/ssh/ssh-hostile-host-test-harness.ts +++ b/src/main/ssh/ssh-hostile-host-test-harness.ts @@ -1,7 +1,6 @@ /** - * The host-agnostic hostile-host driver: the real client-side relay deploy through a real - * SshConnection, observed through the ladder's own calls and a {@link HostileHostObserver}. - * The Docker matrix and the Windows SSH-host lanes both run cells through it. + * The host-agnostic hostile-host setup: app environment and a real SshConnection. The Docker + * matrix and the Windows SSH-host lanes both run cells through it. */ import { mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/ssh/ssh-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-hostile-hosts.docker.test.ts index cc1f3033535a..3bf17e21c0aa 100644 --- a/src/main/ssh/ssh-hostile-hosts.docker.test.ts +++ b/src/main/ssh/ssh-hostile-hosts.docker.test.ts @@ -1,9 +1,9 @@ -// Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH -// targets, and against a macOS runner's own loopback sshd, asserting which rung of the runtime -// ladder each host lands on, and for cells naming one, the runtime managed orcad deploys on. +// Hostile-host matrix: the real client-side managed orcad deploy against container SSH targets, +// and against a macOS runner's own loopback sshd, asserting the runtime each host resolves and +// whether orcad activates there or fails with the classified reason. // // Run: ORCA_RUN_SSH_HOSTILE_HOSTS=1 pnpm test src/main/ssh/ssh-hostile-hosts.docker.test.ts -// Needs `pnpm build:relay` and an orcad template holding each selected cell's slot. Docker cells +// Needs an orcad template holding each selected cell's slot. Docker cells // need Linux Docker (the no-egress cell dials an internal bridge directly) and // node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl // macOS cells need /usr/sbin/sshd and this runner's slot (`pnpm build:orcad-prebuilds`, then @@ -31,6 +31,7 @@ const CELL_TIMEOUT_MS = 15 * 60_000 describe('SSH hostile-host managed orcad matrix', () => { let cleanupAppEnvironment: (() => void) | null = null + let executed = 0 beforeAll(() => { cleanupAppEnvironment = installHostileHostAppEnvironment() @@ -38,18 +39,22 @@ describe('SSH hostile-host managed orcad matrix', () => { afterAll(() => { cleanupAppEnvironment?.() + // Why: a job whose cells were all skipped would otherwise pass having deployed nothing. + if (RUN) { + expect(executed).toBe(SELECTED.size) + } }) - // Why: with every cell skipped the job would pass having deployed nothing. it.runIf(RUN)('selects at least one cell this machine can host', () => { expect(SELECTED.size).toBeGreaterThan(0) }) // A fresh host per cell. - for (const cell of HOSTILE_HOST_CELLS.filter((candidate) => candidate.managed)) { + for (const cell of HOSTILE_HOST_CELLS) { it.skipIf(!SELECTED.has(cell.id))( - `${cell.id} deploys managed orcad on ${cell.managed?.runtime} (${cell.managed?.outcome})`, + `${cell.id} deploys managed orcad on ${cell.managed.runtime} (${cell.managed.outcome})`, async () => { + executed += 1 let target: HostileHostTarget | null = null try { target = await startHostileHostTarget(cell) diff --git a/src/main/ssh/ssh-windows-host-cells.test.ts b/src/main/ssh/ssh-windows-host-cells.test.ts index 8e09f5a1d245..f184b461ec95 100644 --- a/src/main/ssh/ssh-windows-host-cells.test.ts +++ b/src/main/ssh/ssh-windows-host-cells.test.ts @@ -21,8 +21,8 @@ const DESCRIPTOR = { } describe('Windows SSH-host cells', () => { - it('expects rung A for both DefaultShell cases and the ladder skipped when opted out', () => { - const cells = WINDOWS_HOST_CELL_IDS.map((id) => windowsHostCell(id, 'win32-x64')) + it('pairs each relay cell with its DefaultShell and remote runtime', () => { + const cells = WINDOWS_HOST_CELL_IDS.map((id) => windowsHostCell(id)) expect( cells.map(({ id, defaultShell, remoteRuntime }) => [id, defaultShell, remoteRuntime]) ).toEqual([ @@ -30,12 +30,6 @@ describe('Windows SSH-host cells', () => { ['pinned-powershell', 'powershell', 'pinned-node'], ['legacy-opt-out', 'cmd', 'legacy'] ]) - expect(cells[0].expect).toEqual({ - outcome: 'launched', - rung: 'A', - target: 'win32-x64' - }) - expect(cells[2].expect).toEqual({ outcome: 'legacy_opt_out' }) }) it('shims the Windows compilers alongside the Linux toolchain list', () => { @@ -52,7 +46,7 @@ describe('Windows SSH-host cells', () => { if (!isWindowsHostCellId(parsed.cell)) { throw new Error(`expected a relay cell, got ${parsed.cell}`) } - const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell, parsed.target), 'r1') + const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell), 'r1') expect(target).toMatchObject({ id: 'windows-host-pinned-powershell-r1', host: '127.0.0.1', diff --git a/src/main/ssh/ssh-windows-host-cells.ts b/src/main/ssh/ssh-windows-host-cells.ts index d3f8a070fd3e..9b6032dbb3df 100644 --- a/src/main/ssh/ssh-windows-host-cells.ts +++ b/src/main/ssh/ssh-windows-host-cells.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs' import { isWindowsServerTarget, type WindowsServerTarget } from '../../shared/node-runtime-pin' import type { SshRemoteRuntime, SshTarget } from '../../shared/ssh-types' -import { FORBIDDEN_TOOLS, type HostileHostCellCore } from './ssh-hostile-host-cells' +import { FORBIDDEN_TOOLS } from './ssh-hostile-host-cells' /** `cmd` is stock sshd (no DefaultShell value); `powershell` sets DefaultShell to Windows PowerShell. */ export const WINDOWS_SSH_DEFAULT_SHELLS = ['cmd', 'powershell'] as const @@ -22,7 +22,8 @@ export const WINDOWS_FORBIDDEN_TOOLS = [ 'cmake' ] as const -export type WindowsHostCell = HostileHostCellCore & { +export type WindowsHostCell = { + id: string defaultShell: WindowsSshDefaultShell remoteRuntime: SshRemoteRuntime } @@ -34,32 +35,25 @@ export function isWindowsHostCellId(id: string): id is WindowsHostCellId { return WINDOWS_HOST_CELL_IDS.some((candidate) => candidate === id) } -export function windowsHostCell( - id: WindowsHostCellId, - target: WindowsServerTarget -): WindowsHostCell { - const launched = { outcome: 'launched', rung: 'A', target } as const +export function windowsHostCell(id: WindowsHostCellId): WindowsHostCell { switch (id) { case 'pinned-cmd': return { id, defaultShell: 'cmd', - remoteRuntime: 'pinned-node', - expect: launched + remoteRuntime: 'pinned-node' } case 'pinned-powershell': return { id, defaultShell: 'powershell', - remoteRuntime: 'pinned-node', - expect: launched + remoteRuntime: 'pinned-node' } case 'legacy-opt-out': return { id, defaultShell: 'cmd', - remoteRuntime: 'legacy', - expect: { outcome: 'legacy_opt_out' } + remoteRuntime: 'legacy' } } } From c69f9d2f625c7b8ba9b7aabc77698473ebef372c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:34 -0700 Subject: [PATCH 34/58] Add manual cleanup for worktree Electron storage (#27414) * Add manual worktree storage cleanup command * Validate cleanup exclusions and preserve failure diagnostics --- config/scripts/dev-electron-bundle-cache.mjs | 11 +- .../scripts/dev-electron-bundle-cache.test.ts | 11 ++ .../install-electron-package-binary.mjs | 2 +- config/scripts/rebuild-native-deps.mjs | 14 +++ .../scripts/reclaim-dev-electron-bundles.mjs | 52 ++++++++- .../reclaim-dev-electron-bundles.test.ts | 62 +++++++++- config/scripts/reclaim-electron-dists.mjs | 7 +- config/scripts/reclaim-worktree-space.mjs | 107 ++++++++++++++++++ .../scripts/reclaim-worktree-space.test.mjs | 89 +++++++++++++++ docs/readme/worktree-storage-maintenance.md | 44 +++++++ package.json | 1 + 11 files changed, 388 insertions(+), 12 deletions(-) create mode 100644 config/scripts/reclaim-worktree-space.mjs create mode 100644 config/scripts/reclaim-worktree-space.test.mjs create mode 100644 docs/readme/worktree-storage-maintenance.md diff --git a/config/scripts/dev-electron-bundle-cache.mjs b/config/scripts/dev-electron-bundle-cache.mjs index 244d1c964c95..e4a389822461 100644 --- a/config/scripts/dev-electron-bundle-cache.mjs +++ b/config/scripts/dev-electron-bundle-cache.mjs @@ -58,12 +58,21 @@ export function isDevBundleInUse(dir, processTable) { return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`) } -export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) { +export function selectStaleDevBundleDirs({ + bundles, + currentDir, + processTable, + nowMs, + minAgeMs = 0 +}) { return bundles .filter(({ dir, hasMarker, mtimeMs }) => { if (dir === currentDir || isDevBundleInUse(dir, processTable)) { return false } + if (minAgeMs > 0 && nowMs - mtimeMs < minAgeMs) { + return false + } const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS return !buildInFlight }) diff --git a/config/scripts/dev-electron-bundle-cache.test.ts b/config/scripts/dev-electron-bundle-cache.test.ts index ca67a9e1946e..6fdea49dbc13 100644 --- a/config/scripts/dev-electron-bundle-cache.test.ts +++ b/config/scripts/dev-electron-bundle-cache.test.ts @@ -22,6 +22,17 @@ function psLine(dir: string, appName = 'Orca: some-branch') { } describe('dev-electron-bundle-cache', () => { + it('keeps recently used bundles during automatic retention sweeps', () => { + expect( + selectStaleDevBundleDirs({ + bundles: [settled(A), { ...settled(B), mtimeMs: NOW - 8 * 24 * 60 * 60 * 1000 }], + currentDir: null, + processTable: '', + nowMs: NOW, + minAgeMs: 7 * 24 * 60 * 60 * 1000 + }) + ).toEqual([B]) + }) it('reclaims siblings while keeping the current bundle', () => { expect( selectStaleDevBundleDirs({ diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index a9d2bd8c0007..ba6fe406f82d 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -29,7 +29,6 @@ const projectDir = resolve(import.meta.dirname, '../..') const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronRequire = createRequire(resolve(electronPackageDir, 'package.json')) const { version: electronVersion } = electronRequire('./package.json') -const { downloadArtifact } = electronRequire('@electron/get') const targetPlatform = getElectronTargetPlatform() const targetArch = getElectronTargetArch() const platformPath = getElectronPlatformPath(targetPlatform) @@ -249,6 +248,7 @@ function formatShareError(error) { } async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) { + const { downloadArtifact } = electronRequire('@electron/get') const retryDelays = getDownloadRetryDelays() for (let attempt = 0; ; attempt += 1) { diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index e565eeca97e0..f0f6a5352aaa 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -19,6 +19,10 @@ */ import { rebuild } from '@electron/rebuild' +import { + hasAdoptedSharedElectronDist, + resolveSharedElectronDistEntry +} from './shared-electron-dist-cache.mjs' import { execFileSync, spawnSync } from 'node:child_process' import { ensureWindowsProcessTreeCommandLinePatch, @@ -310,6 +314,16 @@ function restoreNodePtyWindowsConptyRuntime() { function ensureElectronPackageInstalled() { repairElectronPathFile() if (electronPackageIsUsable()) { + const shared = resolveSharedElectronDistEntry({ + repoRoot: projectDir, + electronPackageDir, + version: electronVersion, + targetPlatform: electronInstallPlatform, + targetArch: electronInstallArch + }) + if (shared && !hasAdoptedSharedElectronDist(shared)) { + runElectronPackageBinaryInstall() + } return } diff --git a/config/scripts/reclaim-dev-electron-bundles.mjs b/config/scripts/reclaim-dev-electron-bundles.mjs index c2d8a9fd8867..35df4fb39e0b 100644 --- a/config/scripts/reclaim-dev-electron-bundles.mjs +++ b/config/scripts/reclaim-dev-electron-bundles.mjs @@ -11,7 +11,7 @@ // now shared, rebuilding is cheap. import { execFileSync } from 'node:child_process' -import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' +import { existsSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs' import path from 'node:path' import { DEV_BUNDLE_MARKER_FILENAME, @@ -20,6 +20,14 @@ import { } from './dev-electron-bundle-cache.mjs' const apply = process.argv.includes('--apply') +const minAgeIndex = process.argv.indexOf('--min-age-days') +const minAgeDays = minAgeIndex === -1 ? 0 : Number(process.argv[minAgeIndex + 1]) +const excludeIndex = process.argv.indexOf('--exclude-worktree') +const excludeArgument = excludeIndex === -1 ? undefined : process.argv[excludeIndex + 1] +if (excludeIndex !== -1 && (!excludeArgument || excludeArgument.startsWith('-'))) { + throw new Error('--exclude-worktree requires a directory') +} +const excludeWorktree = excludeArgument ? realpathSync(excludeArgument) : null const repoRoot = process.argv.includes('--repo') ? path.resolve(process.argv[process.argv.indexOf('--repo') + 1]) : process.cwd() @@ -34,6 +42,14 @@ function listWorktrees(root) { .map((line) => line.slice('worktree '.length).trim()) } +function canonicalWorktreePath(worktree) { + try { + return realpathSync(worktree) + } catch { + return path.resolve(worktree) + } +} + function measure(targetPath) { let total = 0 let entries @@ -72,6 +88,9 @@ export function collectDevBundles(worktree) { } function main() { + if (!Number.isFinite(minAgeDays) || minAgeDays < 0) { + throw new Error('--min-age-days requires a non-negative number') + } // The patched dev bundle is only built on macOS; elsewhere the dev app runs from dist directly. if (process.platform !== 'darwin') { console.log('No dev Electron bundles on this platform; nothing to reclaim.') @@ -86,14 +105,17 @@ function main() { return } - const bundles = listWorktrees(repoRoot).flatMap((worktree) => collectDevBundles(worktree)) + const bundles = listWorktrees(repoRoot) + .filter((worktree) => canonicalWorktreePath(worktree) !== excludeWorktree) + .flatMap((worktree) => collectDevBundles(worktree)) // currentDir is null on purpose: unlike the dev runner, this sweep is not about to launch anything, // so the only thing protecting a bundle is a live process or an in-flight build. const stale = selectStaleDevBundleDirs({ bundles, currentDir: null, processTable, - nowMs: Date.now() + nowMs: Date.now(), + minAgeMs: minAgeDays * 24 * 60 * 60 * 1000 }) let reclaimed = 0 @@ -107,20 +129,40 @@ function main() { continue } try { + const currentProcessTable = getDevBundleProcessTable() + if (currentProcessTable === null) { + throw new Error('Process ownership is unverifiable') + } + const currentBundle = collectDevBundles(path.resolve(dir, '..', '..', '..')).find( + (bundle) => bundle.dir === dir + ) + if ( + !currentBundle || + !selectStaleDevBundleDirs({ + bundles: [currentBundle], + currentDir: null, + processTable: currentProcessTable, + nowMs: Date.now(), + minAgeMs: minAgeDays * 24 * 60 * 60 * 1000 + }).includes(dir) + ) { + continue + } rmSync(dir, { recursive: true, force: true }) reclaimed += size removed += 1 console.log(`removed ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`) } catch (error) { console.warn(`skip ${dir} (${error instanceof Error ? error.message : String(error)})`) + process.exitCode = 1 } } const inUse = bundles.length - stale.length console.log( `\n${apply ? 'Removed' : 'Would remove'} ${removed} bundle(s); ` + - `${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` + - `${inUse > 0 ? `; left ${inUse} in use or still building` : ''}` + + `logical file data ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB (shared blocks affect disk savings)` + + `${inUse > 0 ? `; retained ${inUse} protected or recent bundle(s)` : ''}` + `${apply ? '' : '\nRe-run with --apply to do it.'}` ) } diff --git a/config/scripts/reclaim-dev-electron-bundles.test.ts b/config/scripts/reclaim-dev-electron-bundles.test.ts index 6a77f970906c..81d91e3683f4 100644 --- a/config/scripts/reclaim-dev-electron-bundles.test.ts +++ b/config/scripts/reclaim-dev-electron-bundles.test.ts @@ -1,7 +1,8 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '@orca/process-host' import { DEV_BUNDLE_MARKER_FILENAME, getDevBundleProcessTable, @@ -51,6 +52,65 @@ describe('collectDevBundles', () => { }) describe('sweeping across worktrees', () => { + it.each([ + ['--apply', '--exclude-worktree'], + ['--exclude-worktree', '--apply'], + ['--exclude-worktree', '-invalid', '--apply'] + ])('rejects invalid exclusion operands before cleanup: %j', async (...flags) => { + const worktree = makeWorktree([{ name: 'idle', marker: true }]) + const initialized = await runProcess({ program: 'git', args: ['-C', worktree, 'init', '-q'] }) + expect(initialized.code).toBe(0) + const result = await runProcess({ + program: process.execPath, + args: [ + path.join(import.meta.dirname, 'reclaim-dev-electron-bundles.mjs'), + '--repo', + worktree, + ...flags + ] + }) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain('--exclude-worktree requires a directory') + expect(existsSync(path.join(worktree, 'out', 'electron-dev', 'idle'))).toBe(true) + }) + + it.skipIf(process.platform !== 'darwin')( + 'previews and removes only bundles outside the retention window', + async () => { + const worktree = makeWorktree([ + { name: 'old', marker: true }, + { name: 'recent', marker: true } + ]) + const initialized = await runProcess({ program: 'git', args: ['-C', worktree, 'init', '-q'] }) + expect(initialized.code).toBe(0) + const old = path.join(worktree, 'out', 'electron-dev', 'old') + const recent = path.join(worktree, 'out', 'electron-dev', 'recent') + const aged = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + utimesSync(old, aged, aged) + const args = [ + path.join(import.meta.dirname, 'reclaim-dev-electron-bundles.mjs'), + '--repo', + worktree, + '--min-age-days', + '7' + ] + const preview = await runProcess({ program: process.execPath, args }) + expect(preview.code, preview.stderr).toBe(0) + expect(preview.stdout).toContain('Would remove 1 bundle(s)') + expect(existsSync(old)).toBe(true) + const excluded = await runProcess({ + program: process.execPath, + args: [...args, '--exclude-worktree', worktree, '--apply'] + }) + expect(excluded.code, excluded.stderr).toBe(0) + expect(existsSync(old)).toBe(true) + const applied = await runProcess({ program: process.execPath, args: [...args, '--apply'] }) + expect(applied.code, applied.stderr).toBe(0) + expect(existsSync(old)).toBe(false) + expect(existsSync(recent)).toBe(true) + } + ) + it('spares a bundle a live process is running from, and takes the idle ones', () => { const worktree = makeWorktree([ { name: 'live', marker: true }, diff --git a/config/scripts/reclaim-electron-dists.mjs b/config/scripts/reclaim-electron-dists.mjs index c3a0fb5b42f0..2276a5f862d3 100644 --- a/config/scripts/reclaim-electron-dists.mjs +++ b/config/scripts/reclaim-electron-dists.mjs @@ -1,8 +1,7 @@ #!/usr/bin/env node // Converts worktrees that already have their own Electron dist over to the shared cache. -// A normal install only shares when Electron is (re)installed, and an existing healthy worktree -// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade. +// Healthy installs also share now; this migrates worktrees without waiting for their next install. import { execFileSync } from 'node:child_process' import { randomUUID } from 'node:crypto' @@ -153,7 +152,7 @@ function main() { recordAdoptedSharedElectronDist(entry, writeFileSync) reclaimed += size converted += 1 - console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`) + console.log(`shared ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB logical file data`) } } catch (error) { // A worktree that fails is left exactly as it was; it still has its own working dist. @@ -164,7 +163,7 @@ function main() { console.log( `\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` + - `${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` + + `logical file data ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB (shared blocks affect disk savings)` + `${skipped > 0 ? `; skipped ${skipped}` : ''}` + `${apply ? '' : '\nRe-run with --apply to do it.'}` ) diff --git a/config/scripts/reclaim-worktree-space.mjs b/config/scripts/reclaim-worktree-space.mjs new file mode 100644 index 000000000000..561a5ea4623f --- /dev/null +++ b/config/scripts/reclaim-worktree-space.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import { mkdir } from 'node:fs/promises' +import { resolve, join } from 'node:path' +import { lock } from 'proper-lockfile' +import { runProcess } from '@orca/process-host' + +export async function reclaimWorktreeSpace({ + repoRoot = process.cwd(), + apply = false, + execute = runProcess, + acquireLock = lock, + commonDirectory +} = {}) { + if (!commonDirectory) { + const result = await runProcess({ + program: 'git', + args: ['-C', repoRoot, 'rev-parse', '--git-common-dir'] + }) + if (result.code !== 0 || result.timedOut || !result.stdout.trim()) { + throw new Error('Run this command inside a local Git repository, or supply --repo') + } + commonDirectory = resolve(repoRoot, result.stdout.trim()) + } + const cacheRoot = join(commonDirectory, 'orca-cache') + const lockPath = join(cacheRoot, 'storage-cleanup') + await mkdir(cacheRoot, { recursive: true }) + let release + try { + release = await acquireLock(lockPath, { realpath: false, stale: 600_000, retries: 0 }) + } catch (error) { + if (error.code === 'ELOCKED') { + throw new Error('Another worktree cleanup is already running') + } + throw error + } + let cleanupFailed = false + let cleanupError + try { + const scripts = ['reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs'] + for (const script of scripts) { + const args = [resolve(import.meta.dirname, script), '--repo', repoRoot] + if (apply) { + args.push('--apply') + } + const result = await execute({ + program: process.execPath, + args, + env: process.env, + stdio: 'inherit', + timeoutMs: 10 * 60 * 1000, + terminationBarrier: true + }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`${script} failed; rerun cleanup after resolving the error`) + } + } + } catch (error) { + cleanupFailed = true + cleanupError = error + } + try { + await release() + } catch (error) { + if (!cleanupFailed) { + throw error + } + console.error( + `Failed to release cleanup lock: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (cleanupFailed) { + throw cleanupError + } + return { completed: true } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + if (process.argv.includes('--help')) { + console.log('Usage: pnpm run reclaim:worktree-space [--repo DIRECTORY] [--apply]') + console.log( + 'Previews Electron distribution sharing and idle dev bundle removal; --apply performs them.' + ) + process.exit(0) + } + const repoIndex = process.argv.indexOf('--repo') + for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index] + if (argument === '--repo') { + index += 1 + } else if (argument !== '--apply') { + throw new Error(`Unknown argument: ${argument}`) + } + } + if ( + repoIndex !== -1 && + (!process.argv[repoIndex + 1] || process.argv[repoIndex + 1].startsWith('--')) + ) { + throw new Error('--repo requires a directory') + } + await reclaimWorktreeSpace({ + repoRoot: repoIndex !== -1 ? resolve(process.argv[repoIndex + 1]) : process.cwd(), + apply: process.argv.includes('--apply') + }).catch((error) => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs new file mode 100644 index 000000000000..f449408d204f --- /dev/null +++ b/config/scripts/reclaim-worktree-space.test.mjs @@ -0,0 +1,89 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { reclaimWorktreeSpace } from './reclaim-worktree-space.mjs' + +const roots = [] +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-storage-maintenance-')) + roots.push(root) + const execute = vi.fn(async () => ({ code: 0, timedOut: false })) + return { commonDirectory: root, repoRoot: root, execute } +} + +it('previews both existing cleanup operations without applying either', async () => { + const options = await fixture() + await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(2) + expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply') +}) + +it('forwards apply and repository selection to both cleanup operations', async () => { + const options = { ...(await fixture()), apply: true } + await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(2) + for (const [spec] of options.execute.mock.calls) { + expect(spec.args).toContain('--apply') + expect(spec.args).toContain(options.repoRoot) + expect(spec.terminationBarrier).toBe(true) + } +}) + +it('rejects competing sweeps and releases the lock after a failed operation', async () => { + const options = { ...(await fixture()), apply: true } + let release + const blocked = new Promise((resolve) => { + release = resolve + }) + let entered + const ready = new Promise((resolve) => { + entered = resolve + }) + options.execute.mockImplementation(async () => { + entered() + await blocked + return { code: 1, timedOut: false } + }) + const first = reclaimWorktreeSpace(options) + const rejected = expect(first).rejects.toThrow('failed') + await ready + await expect(reclaimWorktreeSpace(options)).rejects.toThrow('already running') + release() + await rejected + options.execute.mockResolvedValue({ code: 0, timedOut: false }) + await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(3) +}) + +it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => { + const options = await fixture() + const failure = new Error('original cleanup failure') + options.execute.mockRejectedValue(failure) + const release = vi.fn().mockRejectedValue(new Error('lock release failure')) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect( + reclaimWorktreeSpace({ ...options, acquireLock: async () => release }) + ).rejects.toBe(failure) + expect(release).toHaveBeenCalledTimes(1) + expect(report).toHaveBeenCalledWith('Failed to release cleanup lock: lock release failure') + } finally { + report.mockRestore() + } +}) + +it('fails if cleanup succeeds but the repository lock cannot be released', async () => { + const options = await fixture() + const failure = new Error('lock release failure') + const release = vi.fn().mockRejectedValue(failure) + await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe( + failure + ) + expect(options.execute).toHaveBeenCalledTimes(2) +}) diff --git a/docs/readme/worktree-storage-maintenance.md b/docs/readme/worktree-storage-maintenance.md new file mode 100644 index 000000000000..98e9b98f0271 --- /dev/null +++ b/docs/readme/worktree-storage-maintenance.md @@ -0,0 +1,44 @@ +# Worktree storage maintenance + +Run from the repository to preview maintenance across its locally registered Git worktrees: + +```sh +pnpm run reclaim:worktree-space +``` + +Apply the previewed operations with: + +```sh +pnpm run reclaim:worktree-space --apply +``` + +The command reuses the existing Electron distribution migration and dev bundle cleanup scripts. +It shares complete Electron distributions through the repository cache and removes idle +`out/electron-dev` bundles, which development rebuilds on demand. It leaves source files, +dependency installs, worktree registrations, and other build output alone. Reported sizes are +logical file data; APFS clones and hardlinks mean actual disk savings can be smaller. + +Use `--repo DIRECTORY` to choose another local repository. Git's worktree list determines scope, +including registered worktrees outside Orca's workspace directory. Folder workspaces without Git +have no repository-wide sweep. These commands run on the local host: they do not inspect or delete +anything on SSH hosts. Run maintenance on the execution host when maintaining remote repositories. + +## Keeping installs shared + +Healthy Electron installs join the shared distribution cache during native dependency setup, +without waiting for an Electron upgrade. The manual command migrates older installs immediately. + +Cleanup runs only when explicitly requested. Starting `pnpm dev` does not trigger a sweep. +The repository lock prevents competing cleanup commands. Dev bundle removal checks active +processes and recent unfinished builds, then rechecks eligibility immediately before deletion. +An unreadable process table prevents removal. + +To retain older bundles during a manual sweep, run the underlying command with an age limit: + +```sh +node config/scripts/reclaim-dev-electron-bundles.mjs --min-age-days 7 --apply +``` + +That limit uses the bundle directory's modification time. The underlying command also accepts +`--exclude-worktree DIRECTORY` to protect an entire worktree. A missing exclusion path is an error; +filesystem aliases resolve to the same worktree. Neither option schedules cleanup. diff --git a/package.json b/package.json index 365980432c8a..0082b331a633 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs", "reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs", + "reclaim:worktree-space": "node config/scripts/reclaim-worktree-space.mjs", "rebuild:node": "pnpm rebuild node-pty", "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", From 2d5e9b6992c78d32029fd3ea3b836e211b88c673 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:06:13 -0700 Subject: [PATCH 35/58] fix(pty): only force-kill processes in the terminal's own Linux session (#27422) Container devpts instances reuse pts numbers, so selecting kill targets by tty alone could SIGKILL same-uid processes in other namespaces. On Linux, require each candidate's /proc session id to equal the PTY shell's pid. Fixes #27337 Co-authored-by: m4air --- src/main/pty/posix-pty-process-groups.test.ts | 114 +++++++++++++++++- src/main/pty/posix-pty-process-groups.ts | 47 +++++++- 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/main/pty/posix-pty-process-groups.test.ts b/src/main/pty/posix-pty-process-groups.test.ts index edddd0aebd0b..3772200064b8 100644 --- a/src/main/pty/posix-pty-process-groups.test.ts +++ b/src/main/pty/posix-pty-process-groups.test.ts @@ -1,11 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' + import type { ProcessResult } from '@orca/process-host/process-spec' -const { recordSelfInitiatedTreeKillMock, runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ - recordSelfInitiatedTreeKillMock: vi.fn(), - runProcessMock: vi.fn(), - runProcessSyncMock: vi.fn() +const { readFileSyncMock, recordSelfInitiatedTreeKillMock, runProcessMock, runProcessSyncMock } = + vi.hoisted(() => ({ + readFileSyncMock: vi.fn(), + recordSelfInitiatedTreeKillMock: vi.fn(), + runProcessMock: vi.fn(), + runProcessSyncMock: vi.fn() + })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + readFileSync: readFileSyncMock })) vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ recordSelfInitiatedTreeKill: recordSelfInitiatedTreeKillMock @@ -19,12 +27,28 @@ import { forceKillPosixPtyProcessGroups, getPosixPtyProcessGroups, isPosixPtyRootStopped, + parseLinuxProcStatSessionId, readPosixPtyProcessTable, resetPosixPtyProcessTableDialectForTests, signalPosixPtyProcessGroups } from './posix-pty-process-groups' +// `/proc//stat` fixture: fields after comm are state, ppid, pgrp, session, tty_nr, ... +const procStat = (pid: number, session: number, comm = 'bash'): string => + `${pid} (${comm}) S 1 ${pid} ${session} 34822 ${pid} 4194560 1 0 0 0 0 0 0 0 20 0 1 0 98765 0 0\n` +const procSessions = new Map() + beforeEach(() => { + procSessions.clear() + readFileSyncMock.mockReset() + // Default fixture: each hundred-block of pids is one terminal session led by its round pid. + readFileSyncMock.mockImplementation((path: string) => { + const pid = Number(/^\/proc\/(\d+)\/stat$/.exec(path)?.[1]) + if (!pid) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return procStat(pid, procSessions.get(pid) ?? Math.floor(pid / 100) * 100) + }) recordSelfInitiatedTreeKillMock.mockReset() runProcessMock.mockReset() runProcessSyncMock.mockReset() @@ -412,6 +436,88 @@ describe('POSIX PTY process-group termination', () => { }) }) +describe('Linux terminal session identity (#27337)', () => { + // Container devpts instances reuse pts numbers: tty alone cannot tell host pts/6 from a container's. + const COLLIDING_TABLE = ` +100 100 pts/6 Ss +101 101 pts/6 S+ +500 500 pts/6 Ss +501 500 pts/6 S +600 600 pts/6 Ss +` + + it('parses the session id past a comm containing spaces and parentheses', () => { + expect(parseLinuxProcStatSessionId(procStat(42, 7, 'evil) S 1 2 3 (x'))).toBe(7) + expect(parseLinuxProcStatSessionId('42 (bash')).toBeNull() + expect(parseLinuxProcStatSessionId('42 (bash) S 1 42')).toBeNull() + expect(parseLinuxProcStatSessionId('42 (bash) S 1 42 0 0')).toBeNull() + }) + + it('force-kills only groups in the shell session, not container processes on the same pts', () => { + procSessions.set(500, 500).set(501, 500).set(600, 600) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + readProcessTable: () => COLLIDING_TABLE, + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls).toEqual([[101], [100]]) + expect(fallback).not.toHaveBeenCalled() + expect(readFileSyncMock).toHaveBeenCalledWith('/proc/500/stat', 'utf8') + }) + + it('applies the same session filter to the stop/continue sweep', () => { + procSessions.set(500, 500).set(501, 500).set(600, 600) + const signalProcessGroup = vi.fn() + signalPosixPtyProcessGroups(100, 'SIGSTOP', vi.fn(), { + platform: 'linux', + currentPid: 999, + readProcessTable: () => COLLIDING_TABLE, + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls).toEqual([[100], [101]]) + }) + + it('excludes candidates whose session can no longer be read', () => { + expect( + getPosixPtyProcessGroups(COLLIDING_TABLE, 100, 999, (pid) => + pid === 101 ? null : pid < 200 ? 100 : pid + ) + ).toEqual([100]) + }) + + it.each([ + ['unreadable', () => null], + ['not a session leader', () => 50] + ])('falls back to the root-only kill when the shell session is %s', (_label, sessionOf) => { + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + readProcessTable: () => COLLIDING_TABLE, + readProcessSessionId: sessionOf, + signalProcessGroup + }) + expect(fallback).toHaveBeenCalledOnce() + expect(signalProcessGroup).not.toHaveBeenCalled() + }) + + it('keeps macOS selection by terminal alone without reading /proc', () => { + const signalProcessGroup = vi.fn() + forceKillPosixPtyProcessGroups(100, vi.fn(), { + platform: 'darwin', + currentPid: 999, + readProcessTable: () => COLLIDING_TABLE, + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls).toEqual([[101], [500], [600], [100]]) + expect(readFileSyncMock).not.toHaveBeenCalled() + }) +}) + describe('POSIX PTY group-sweep breadcrumbs', () => { it('records every group it actually signalled', () => { forceKillPosixPtyProcessGroups(100, vi.fn(), { diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts index 1a365dc26a93..981674e961b1 100644 --- a/src/main/pty/posix-pty-process-groups.ts +++ b/src/main/pty/posix-pty-process-groups.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { hasControllingTty } from '../../shared/posix-controlling-terminal' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' @@ -35,6 +36,31 @@ export type PosixPtyProcessGroupTerminationDeps = { currentPid?: number readProcessTable?: () => string signalProcessGroup?: (pgid: number) => void + /** Linux session id of a pid, or null when unreadable. */ + readProcessSessionId?: (pid: number) => number | null +} + +/** Field 6 (`session`) of `/proc//stat`, read past the parenthesised comm. */ +export function parseLinuxProcStatSessionId(stat: string): number | null { + const closingParen = stat.lastIndexOf(')') + if (closingParen === -1) { + return null + } + const session = Number( + stat + .slice(closingParen + 1) + .trim() + .split(/\s+/)[3] + ) + return Number.isSafeInteger(session) && session > 0 ? session : null +} + +function readLinuxProcessSessionId(pid: number): number | null { + try { + return parseLinuxProcStatSessionId(readFileSync(`/proc/${pid}/stat`, 'utf8')) + } catch { + return null + } } function readProcessTableResult(result: ProcessResult): string { @@ -165,7 +191,8 @@ export function isPosixPtyRootStopped(output: string, rootPid: number): boolean export function getPosixPtyProcessGroups( output: string, rootPid: number, - currentPid = process.pid + currentPid = process.pid, + readSessionId?: (pid: number) => number | null ): number[] | null { const rows = parseProcessRows(output) const root = rows.find((row) => row.pid === rootPid) @@ -177,7 +204,17 @@ export function getPosixPtyProcessGroups( if (rows.some((row) => row.pid === currentPid && row.tty === root.tty)) { return null } - const groups = new Set(rows.filter((row) => row.tty === root.tty).map((row) => row.pgid)) + let members = rows.filter((row) => row.tty === root.tty) + if (readSessionId) { + // Why: every devpts instance (e.g. each container) numbers ptys from 0, so a tty + // match alone can name another namespace's session. The node-pty shell leads its own + // session; setsid'd descendants dropped this tty and were never candidates (#16714). + if (readSessionId(rootPid) !== rootPid) { + return null + } + members = members.filter((row) => readSessionId(row.pid) === rootPid) + } + const groups = new Set(members.map((row) => row.pgid)) if (!groups.has(root.pgid)) { return null } @@ -212,7 +249,8 @@ export function signalPosixPtyProcessGroups( fallback: () => void, deps: PosixPtyProcessGroupTerminationDeps = {} ): void { - if ((deps.platform ?? process.platform) === 'win32') { + const platform = deps.platform ?? process.platform + if (platform === 'win32') { fallback() return } @@ -221,7 +259,8 @@ export function signalPosixPtyProcessGroups( groups = getPosixPtyProcessGroups( (deps.readProcessTable ?? (() => readPtyProcessTable(rootPid)))(), rootPid, - deps.currentPid ?? process.pid + deps.currentPid ?? process.pid, + deps.readProcessSessionId ?? (platform === 'linux' ? readLinuxProcessSessionId : undefined) ) } catch { groups = null From 34d7884a5290737a2168a6ca17a04c1fa98362f8 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:06:40 -0700 Subject: [PATCH 36/58] chore(ratchet): one focus-read ratchet (#27426) Fold the empty owner-routing baseline into the focus-read ratchet. The three deleted focus-routing helpers stay banned as a forbidden-name list. Co-authored-by: m4air --- .github/workflows/pr.yml | 4 +- config/focus-setting-read-baseline.txt | 2 +- config/owner-routing-baseline.txt | 4 - ...tchet.mjs => check-focus-read-ratchet.mjs} | 153 ++++++++---------- ....mjs => check-focus-read-ratchet.test.mjs} | 33 ++-- config/scripts/ci-shard-timings.json | 2 +- config/scripts/pr-preflight-gates.test.mjs | 2 +- package.json | 4 +- 8 files changed, 86 insertions(+), 118 deletions(-) delete mode 100644 config/owner-routing-baseline.txt rename config/scripts/{check-owner-routing-ratchet.mjs => check-focus-read-ratchet.mjs} (77%) rename config/scripts/{check-owner-routing-ratchet.test.mjs => check-focus-read-ratchet.test.mjs} (92%) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5ebf30767511..ef2dbf1c8887 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -327,9 +327,9 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet - - name: Enforce owner-routing ratchet + - name: Enforce focus-read ratchet if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:owner-routing-ratchet + run: pnpm run check:focus-read-ratchet - name: Check Node runtime pin if: needs.code_paths.outputs.static_analysis == 'true' diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 777e5cca62a5..bf1bfa49004c 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -2,7 +2,7 @@ # activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file. # This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read # the default host, through defaultCreationHost. Everything else routes by the owner. -# Prune after removing reads: pnpm check:owner-routing-ratchet --prune +# Prune after removing reads: pnpm check:focus-read-ratchet --prune 2 src/renderer/src/App.tsx 5 src/renderer/src/app-shell/use-app-shell-services.ts 9 src/renderer/src/components/JiraIssueWorkspace.tsx diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt deleted file mode 100644 index df69f491126c..000000000000 --- a/config/owner-routing-baseline.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Renderer call sites that route by the Active Server focus setting: -# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file. -# The helpers are deleted and this list is empty: any new use fails lint. Route by the -# resource's owner; only a creation flow with no source row may use defaultCreationHost. diff --git a/config/scripts/check-owner-routing-ratchet.mjs b/config/scripts/check-focus-read-ratchet.mjs similarity index 77% rename from config/scripts/check-owner-routing-ratchet.mjs rename to config/scripts/check-focus-read-ratchet.mjs index 454b1931225a..5ab5e7a322df 100644 --- a/config/scripts/check-owner-routing-ratchet.mjs +++ b/config/scripts/check-focus-read-ratchet.mjs @@ -4,23 +4,29 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -// Two ratchets for renderer code that routes host-owned work by the "Active Server" setting -// instead of by the resource's own host. Per-file counts may only go down. -// - Owner routing: the three focus-routing helpers, counted together so renaming one into another -// never lowers the count. -// - Focus reads: every read of the setting plus every call of an exported function that reads it -// for the caller (discovered, not hand-listed), so swapping one form for another never lowers it. +// Ratchet for renderer code that routes host-owned work by the "Active Server" setting instead of +// by the resource's own host. Per file it counts every read of the setting plus every call of an +// exported function that reads it for the caller (discovered, not hand-listed), so swapping one +// form for another never lowers it. Counts may only go down. The deleted focus-routing helpers +// are banned outright. const SCAN_ROOT = 'src/renderer/src' -const HELPER_NAMES = 'getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner' -const HELPERS = `(?:${HELPER_NAMES})` +const BASELINE_PATH = 'config/focus-setting-read-baseline.txt' +const FORBIDDEN_HELPERS = [ + 'getActiveRuntimeTarget', + 'legacyRouteFromSettings', + 'settingsForRuntimeOwner' +] const IMPORT_EXPORT_LIST = /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\}/g // Calls and value uses (`.map(helper)`); definitions and type queries are not routing. -const FOCUS_ROUTING_USE = new RegExp(`(?` are indexed types (`GlobalSettings['…']`), not reads. @@ -332,36 +338,15 @@ export function hasFocusRoutingAlias(sourceText) { return (sourceText.match(IMPORT_EXPORT_LIST) ?? []).some((list) => alias.test(list)) } -export const RATCHETS = [ - { - name: 'owner-routing', - baselinePath: 'config/owner-routing-baseline.txt', - count: (text) => countFocusRoutingCalls(text), - header: [ - '# Renderer call sites that route by the Active Server focus setting:', - '# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.', - '# The helpers are deleted and this list is empty: any new use fails lint. Route by the', - "# resource's owner; only a creation flow with no source row may use defaultCreationHost." - ], - advice: - "Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting." - }, - { - name: 'focus-setting-read', - baselinePath: 'config/focus-setting-read-baseline.txt', - count: (text, rel, readers) => - countFocusSettingReads(text, readers.namesFor(rel), readers.memberNames), - header: [ - '# Renderer reads of the Active Server setting (member, element and destructuring reads of', - '# activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file.', - '# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read', - '# the default host, through defaultCreationHost. Everything else routes by the owner.', - '# Prune after removing reads: pnpm check:owner-routing-ratchet --prune' - ], - advice: - "Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost." - } +const BASELINE_HEADER = [ + '# Renderer reads of the Active Server setting (member, element and destructuring reads of', + '# activeRuntimeEnvironmentId) plus calls of exported functions that read it, per file.', + '# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read', + '# the default host, through defaultCreationHost. Everything else routes by the owner.', + '# Prune after removing reads: pnpm check:focus-read-ratchet --prune' ] +const ADVICE = + "Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost." export function isScannedPath(rel) { return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel) @@ -393,7 +378,7 @@ export function parseBaselineNotes(text) { return notes } -export function formatBaseline(counts, header = RATCHETS[0].header, notes = new Map()) { +export function formatBaseline(counts, header = BASELINE_HEADER, notes = new Map()) { const rows = [...counts] .filter(([, count]) => count > 0) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) @@ -452,82 +437,84 @@ function scanSources(root) { return scanCache.get(root) } -export function collectCurrentCounts(root = process.cwd(), count = RATCHETS[0].count) { +export function collectCurrentCounts(root = process.cwd()) { const { sources, readers } = scanSources(root) const counts = new Map() + const forbidden = [] const aliased = [] for (const [rel, source] of sources) { if (!rel.startsWith(`${SCAN_ROOT}/`)) { continue } - const found = count(source, rel, readers) + const found = countFocusSettingReads(source, readers.namesFor(rel), readers.memberNames) if (found > 0) { counts.set(rel, found) } + if (countForbiddenHelperUses(source) > 0) { + forbidden.push(rel) + } if (hasFocusRoutingAlias(source)) { aliased.push(rel) } } - return { counts, aliased, readers } + return { counts, forbidden, aliased } } function total(counts) { return [...counts.values()].reduce((sum, count) => sum + count, 0) } -function checkRatchet(root, ratchet) { - const baselineFile = path.join(root, ratchet.baselinePath) +export function main(root = process.cwd()) { + const baselineFile = path.join(root, BASELINE_PATH) if (!fs.existsSync(baselineFile)) { - console.error(`::error::Missing ${ratchet.baselinePath}.`) - return { ok: false, total: 0 } + console.error(`::error::Missing ${BASELINE_PATH}.`) + return 1 } - const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) - const { counts: current } = collectCurrentCounts(root, ratchet.count) - const { grown, shrunk } = diffCounts(current, baseline) - for (const { file, now, allowed } of grown) { + const { counts: current, forbidden, aliased } = collectCurrentCounts(root) + for (const file of forbidden) { console.error( - `::error file=${file}::${ratchet.name}: ${now} use(s), baseline allows ${allowed}. ${ratchet.advice}` + `::error file=${file}::${FORBIDDEN_HELPERS.join(', ')} are deleted focus-routing helpers; do not reintroduce them. ${ADVICE}` ) } - for (const { file, now, allowed } of shrunk) { + for (const file of aliased) { console.error( - `::error file=${file}::${ratchet.name}: ${now} use(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune` + `::error file=${file}::A focus reader is imported or exported under another name, which hides its calls from this ratchet. Use the original name.` ) } - return { ok: grown.length === 0 && shrunk.length === 0, total: total(current) } -} - -export function main(root = process.cwd()) { - const { aliased } = collectCurrentCounts(root) - for (const file of aliased) { + const { grown, shrunk } = diffCounts( + current, + parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + ) + for (const { file, now, allowed } of grown) { console.error( - `::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.` + `::error file=${file}::${now} focus read(s), baseline allows ${allowed}. ${ADVICE}` ) } - let ok = aliased.length === 0 - for (const ratchet of RATCHETS) { - const result = checkRatchet(root, ratchet) - ok = ok && result.ok - if (result.ok) { - console.log(`${ratchet.name} ratchet OK — ${result.total} use(s).`) - } + for (const { file, now, allowed } of shrunk) { + console.error( + `::error file=${file}::${now} focus read(s), baseline still allows ${allowed}. Run: pnpm check:focus-read-ratchet --prune` + ) + } + if (forbidden.length + aliased.length + grown.length + shrunk.length > 0) { + return 1 } - return ok ? 0 : 1 + console.log(`Focus-read ratchet OK — ${total(current)} read(s).`) + return 0 } export function prune(root = process.cwd()) { - for (const ratchet of RATCHETS) { - const baselineFile = path.join(root, ratchet.baselinePath) - const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' - const baseline = parseBaseline(text) - const { counts: current } = collectCurrentCounts(root, ratchet.count) - // Lowers entries only; growth still has to be fixed in the code. - const pruned = new Map( - [...baseline].map(([file, allowed]) => [file, Math.min(allowed, current.get(file) ?? 0)]) - ) - fs.writeFileSync(baselineFile, formatBaseline(pruned, ratchet.header, parseBaselineNotes(text))) - console.log(`Pruned ${ratchet.baselinePath} to ${total(pruned)} use(s).`) - } + const baselineFile = path.join(root, BASELINE_PATH) + const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' + const { counts: current } = collectCurrentCounts(root) + // Lowers entries only; growth still has to be fixed in the code. + const pruned = new Map( + [...parseBaseline(text)].map(([file, allowed]) => [ + file, + Math.min(allowed, current.get(file) ?? 0) + ]) + ) + fs.writeFileSync(baselineFile, formatBaseline(pruned, BASELINE_HEADER, parseBaselineNotes(text))) + console.log(`Pruned ${BASELINE_PATH} to ${total(pruned)} read(s).`) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/check-owner-routing-ratchet.test.mjs b/config/scripts/check-focus-read-ratchet.test.mjs similarity index 92% rename from config/scripts/check-owner-routing-ratchet.test.mjs rename to config/scripts/check-focus-read-ratchet.test.mjs index 8ffb9bd5da29..7d7477fd92eb 100644 --- a/config/scripts/check-owner-routing-ratchet.test.mjs +++ b/config/scripts/check-focus-read-ratchet.test.mjs @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { - countFocusRoutingCalls, countFocusSettingReads, + countForbiddenHelperUses, diffCounts, discoverFocusReaders, formatBaseline, @@ -10,35 +10,20 @@ import { isScannedPath, parseBaseline, parseBaselineNotes -} from './check-owner-routing-ratchet.mjs' +} from './check-focus-read-ratchet.mjs' -describe('countFocusRoutingCalls', () => { - it('counts the three focus-routing helpers together', () => { - const src = [ - 'const a = getActiveRuntimeTarget(settings)', - 'const b = legacyRouteFromSettings(settings)', - 'const c = getActiveRuntimeTarget(settingsForRuntimeOwner(settings, id))' - ].join('\n') - expect(countFocusRoutingCalls(src)).toBe(4) - }) - - it('ignores definitions, type queries and longer names', () => { +describe('countForbiddenHelperUses', () => { + it('counts calls and value uses of the deleted helpers, not definitions, types or imports', () => { const src = [ + "import { callRuntimeRpc, getActiveRuntimeTarget } from './rpc'", + "export {\n settingsForRuntimeOwner,\n type RuntimeClientTarget\n} from './target'", + 'const a = getActiveRuntimeTarget (settingsForRuntimeOwner(settings, id))', + 'const b = list.map(legacyRouteFromSettings)', 'export function getActiveRuntimeTarget(settings) {}', 'type T = ReturnType', 'mygetActiveRuntimeTarget(settings)' ].join('\n') - expect(countFocusRoutingCalls(src)).toBe(0) - }) - - it('counts spaced calls and value uses but not import or export lists', () => { - const src = [ - "import { callRuntimeRpc, getActiveRuntimeTarget } from './rpc'", - "export {\n settingsForRuntimeOwner,\n type RuntimeClientTarget\n} from './target'", - 'const a = getActiveRuntimeTarget (settings)', - 'const b = list.map(getActiveRuntimeTarget)' - ].join('\n') - expect(countFocusRoutingCalls(src)).toBe(2) + expect(countForbiddenHelperUses(src)).toBe(3) }) }) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 1af4266ad64d..769beffd4401 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -28,7 +28,7 @@ "config/scripts/check-daemon-protocol-crossing.test.mjs": 194, "config/scripts/check-max-lines-ratchet.test.mjs": 658, "config/scripts/check-node-runtime-pin.test.mjs": 696, - "config/scripts/check-owner-routing-ratchet.test.mjs": 86, + "config/scripts/check-focus-read-ratchet.test.mjs": 86, "config/scripts/check-process-host-imports.test.mjs": 301, "config/scripts/check-react-doctor-changed.test.mjs": 229, "config/scripts/check-readme-local-links.test.mjs": 414, diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index d53e09fb3d57..5c250aba625f 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -223,7 +223,7 @@ it('pins every foreground and background step to its selected phase', () => { ['Enforce max-lines ratchet', staticPhase], ['Enforce ts-nocheck ratchet', staticPhase], ['Enforce runtime Electron-import ratchet', staticPhase], - ['Enforce owner-routing ratchet', staticPhase], + ['Enforce focus-read ratchet', staticPhase], ['Check Node runtime pin', staticPhase], ['Boot orcad and round-trip a terminal', staticPhase], ['Verify the generated RPC params catalog', staticPhase], diff --git a/package.json b/package.json index 0082b331a633..d277f4045be0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:owner-routing-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports", "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-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", @@ -39,7 +39,7 @@ "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", - "check:owner-routing-ratchet": "node config/scripts/check-owner-routing-ratchet.mjs", + "check:focus-read-ratchet": "node config/scripts/check-focus-read-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", "check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs", "check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs", From e3d747b78bff2cbac399e168b220a287084d7a56 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:08:46 -0400 Subject: [PATCH 37/58] fix(runtime): scope layout.unsubscribe to the caller's connection (#27423) A layout stream's cleanup is now registered under a key that includes the subscriber's connection, and layout.unsubscribe builds that key from the caller's own connection, matching session.tabs.unsubscribe. Another connection can no longer end a stream by passing its id. --- .../rpc/methods/workspace-layout.test.ts | 51 ++++++++++++++++--- .../runtime/rpc/methods/workspace-layout.ts | 13 +++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/rpc/methods/workspace-layout.test.ts b/src/main/runtime/rpc/methods/workspace-layout.test.ts index 58279940e7e9..d57fd5f428c4 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.test.ts @@ -6,9 +6,9 @@ import { ALL_RPC_METHODS } from './index' import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist' -const subscribe = eraseRpcMethods(ALL_RPC_METHODS).find( - (method) => method.name === 'layout.subscribe' -) +const methods = eraseRpcMethods(ALL_RPC_METHODS) +const subscribe = methods.find((method) => method.name === 'layout.subscribe') +const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe') function harness() { const cleanups = new Map void>() @@ -57,19 +57,58 @@ describe('layout.subscribe', () => { { runtime, connectionId: 'conn-1' } as RpcContext, (event) => emitted.push(event) ) - const [subscriptionId] = [...cleanups.keys()] + const [cleanupKey] = [...cleanups.keys()] expect(emitted).toEqual([ { type: 'snapshot', - subscriptionId, + subscriptionId: expect.any(String), workspaces: [{ key: 'b', layout: { worktreeId: 'b' } }] } ]) publish({ type: 'removed', key: 'a' }) publish({ type: 'removed', key: 'b' }) - runtime.cleanupSubscription(subscriptionId!) + runtime.cleanupSubscription(cleanupKey!) await done expect(emitted.slice(1)).toEqual([{ type: 'removed', key: 'b' }, { type: 'end' }]) expect(unsubscribe).toHaveBeenCalledTimes(1) }) + + it('ends a subscription only for the connection that opened it', async () => { + const { runtime, unsubscribe } = harness() + const emitted: unknown[] = [] + if (!subscribe || !isStreamingMethod(subscribe) || !unsubscribeMethod) { + throw new Error('layout methods are missing') + } + if (isStreamingMethod(unsubscribeMethod)) { + throw new Error('layout.unsubscribe is not a request method') + } + const done = subscribe.handler( + undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context. + { runtime, connectionId: 'conn-1' } as RpcContext, + (event) => emitted.push(event) + ) + const [snapshot] = emitted + const subscriptionId = + snapshot && typeof snapshot === 'object' && 'subscriptionId' in snapshot + ? snapshot.subscriptionId + : undefined + expect(subscriptionId).toEqual(expect.any(String)) + + await unsubscribeMethod.handler( + { subscriptionId }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context. + { runtime, connectionId: 'conn-2' } as RpcContext + ) + expect(unsubscribe).not.toHaveBeenCalled() + + await unsubscribeMethod.handler( + { subscriptionId }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context. + { runtime, connectionId: 'conn-1' } as RpcContext + ) + await done + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(emitted.at(-1)).toEqual({ type: 'end' }) + }) }) diff --git a/src/main/runtime/rpc/methods/workspace-layout.ts b/src/main/runtime/rpc/methods/workspace-layout.ts index 4e2224a74254..e4d260898668 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.ts @@ -10,6 +10,10 @@ import { defineMethod, defineStreamingMethod } from '../core' let layoutSubscriptionSeq = 0 +// Why: keyed by the caller's connection, as session.tabs is, so only the subscriber's connection can end it. +const layoutCleanupKey = (connectionId: string | undefined, subscriptionId: string): string => + `layout:${connectionId ?? 'local'}:${subscriptionId}` + export const WORKSPACE_LAYOUT_METHODS = [ defineStreamingMethod({ name: 'layout.subscribe', @@ -22,14 +26,15 @@ export const WORKSPACE_LAYOUT_METHODS = [ const wanted = (key: string) => !Array.isArray(keys) || keys.includes(key) await new Promise((resolve) => { const subscriptionId = `layout-${connectionId ?? 'inproc'}-${++layoutSubscriptionSeq}` - const release = (): void => runtime.cleanupSubscription(subscriptionId) + const cleanupKey = layoutCleanupKey(connectionId, subscriptionId) + const release = (): void => runtime.cleanupSubscription(cleanupKey) const { snapshot, unsubscribe } = runtime.subscribeWorkspaceLayouts((event) => { if (wanted(event.key)) { emit(event) } }) runtime.registerSubscriptionCleanup( - subscriptionId, + cleanupKey, () => { unsubscribe() signal?.removeEventListener('abort', release) @@ -54,8 +59,8 @@ export const WORKSPACE_LAYOUT_METHODS = [ name: 'layout.unsubscribe', permission: 'workspace', params: LayoutUnsubscribeParams, - handler: async (params, { runtime }) => { - runtime.cleanupSubscription(params.subscriptionId) + handler: async (params, { runtime, connectionId }) => { + runtime.cleanupSubscription(layoutCleanupKey(connectionId, params.subscriptionId)) return { unsubscribed: true } } }) From 6239c0c5827696491dc1d42413739e0c50ba06ff Mon Sep 17 00:00:00 2001 From: Sergei Tiron <1ivi41337@gmail.com> Date: Sun, 11 Oct 2026 11:28:05 +0200 Subject: [PATCH 38/58] fix(linux): stop the AppImage crashpad handler before the mount goes away (#23459) * fix(linux): stop the AppImage crashpad handler before the mount goes away Crashpad's handler runs from the AppImage FUSE mount and outlives the main process, so it SIGBUSes once the runtime unmounts it. On a validated AppImage running from its own mount, SIGKILL this launch's handler from the main process's exit hook. Fixes #22542 Co-Authored-By: Claude Opus 5.5 * docs: clarify AppImage Crashpad exit hook --------- Co-authored-by: Claude Opus 5.5 --- .../appimage-crashpad-handler-exit.test.ts | 79 +++++++++++++++++++ .../appimage-crashpad-handler-exit.ts | 61 ++++++++++++++ src/main/crash-reporting/crashpad-capture.ts | 2 + 3 files changed, 142 insertions(+) create mode 100644 src/main/crash-reporting/appimage-crashpad-handler-exit.test.ts create mode 100644 src/main/crash-reporting/appimage-crashpad-handler-exit.ts diff --git a/src/main/crash-reporting/appimage-crashpad-handler-exit.test.ts b/src/main/crash-reporting/appimage-crashpad-handler-exit.test.ts new file mode 100644 index 000000000000..35999017d478 --- /dev/null +++ b/src/main/crash-reporting/appimage-crashpad-handler-exit.test.ts @@ -0,0 +1,79 @@ +import type * as fs from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' +import { installAppImageCrashpadHandlerExit } from './appimage-crashpad-handler-exit' + +vi.mock('../appimage-runtime-identity', () => ({ + resolveAppImageRuntimeIdentity: vi.fn() +})) + +const MOUNT = '/tmp/.mount_Orca-abc123' +const PROC_EXECUTABLES: Record = { + '40': `${MOUNT}/chrome_crashpad_handler`, + '41': `${MOUNT}/chrome_crashpad_handler`, + '42': `${MOUNT}/orca-ide`, + // Another launch's mount, and a handler whose mount is already gone. + '43': '/tmp/.mount_Orca-zzz999/chrome_crashpad_handler', + '44': '/chrome_crashpad_handler' +} + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + readdirSync: vi.fn(() => [...Object.keys(PROC_EXECUTABLES), '45', 'self']), + readlinkSync: vi.fn((path: string) => { + const executable = PROC_EXECUTABLES[path.split('/')[2]] + if (!executable) { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + } + return executable + }), + // Only the FUSE mount root sits on its own device. + statSync: vi.fn((path: string) => ({ dev: path === MOUNT ? 2 : 1 })) +})) + +describe('installAppImageCrashpadHandlerExit', () => { + const originalExecPath = process.execPath + + afterEach(() => { + process.execPath = originalExecPath + vi.restoreAllMocks() + }) + + function installExitHook( + isAppImage: boolean, + execPath = `${MOUNT}/orca-ide` + ): (() => void) | undefined { + process.execPath = execPath + vi.mocked(resolveAppImageRuntimeIdentity).mockReturnValue( + isAppImage ? { appImagePath: '/home/u/orca-linux.AppImage' } : null + ) + const once = vi.spyOn(process, 'once').mockReturnValue(process) + installAppImageCrashpadHandlerExit() + return once.mock.calls.find(([event]) => event === 'exit')?.[1] + } + + it('SIGKILLs only this launch handler, surviving one that already exited', () => { + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 40) { + throw Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }) + } + return true + }) + + installExitHook(true)?.() + + expect(kill.mock.calls).toEqual([ + [40, 'SIGKILL'], + [41, 'SIGKILL'] + ]) + }) + + // A deb/rpm handler binary is shared by every instance, so it must never be matched. + it('does not hook exit outside a validated AppImage', () => { + expect(installExitHook(false)).toBeUndefined() + }) + + it('does not hook exit under extract-and-run, whose dir is shared across launches', () => { + expect(installExitHook(true, '/tmp/appimage_extracted_0123abcd/orca-ide')).toBeUndefined() + }) +}) diff --git a/src/main/crash-reporting/appimage-crashpad-handler-exit.ts b/src/main/crash-reporting/appimage-crashpad-handler-exit.ts new file mode 100644 index 000000000000..03a33edecc89 --- /dev/null +++ b/src/main/crash-reporting/appimage-crashpad-handler-exit.ts @@ -0,0 +1,61 @@ +// AppImage: Crashpad's handler outlives us on the FUSE mount and SIGBUSes once it is unmounted +// (#22542). Stopped, not relocated: Electron always launches it from the executable's directory. +// A main-process crash runs no exit hook, so the handler still faults after writing that dump. + +import { readdirSync, readlinkSync, statSync } from 'node:fs' +// posix: /proc paths are Linux-only, and tests run this matcher on every host. +import { posix } from 'node:path' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' + +function listPids(): number[] { + try { + return readdirSync('/proc') + .filter((entry) => /^\d+$/.test(entry)) + .map(Number) + } catch { + return [] + } +} + +function readExecutable(pid: number): string | null { + try { + return readlinkSync(`/proc/${pid}/exe`) + } catch { + return null + } +} + +// Extract-and-run reuses one digest-named dir across concurrent launches and has no FUSE mount to lose. +function isRunningFromOwnMount(runtimeRoot: string): boolean { + try { + return statSync(runtimeRoot).dev !== statSync(posix.dirname(runtimeRoot)).dev + } catch { + return false + } +} + +/** + * Use `exit`: Electron can defer `will-quit`; this hook runs only in Node's final exit phase. + */ +export function installAppImageCrashpadHandlerExit(): void { + const runtimeRoot = posix.dirname(process.execPath) + // Why resolve now: validation reads the AppImage file, which may be moved or deleted by exit. + if (!resolveAppImageRuntimeIdentity() || !isRunningFromOwnMount(runtimeRoot)) { + return + } + // The mount path is unique per launch, so only this launch's handler matches. + const handlerPath = posix.join(runtimeRoot, 'chrome_crashpad_handler') + process.once('exit', () => { + for (const pid of listPids()) { + if (readExecutable(pid) !== handlerPath) { + continue + } + try { + // Why SIGKILL: SIGTERM runs the handler's own signal code, whose cold pages race the unmount. + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } + } + }) +} diff --git a/src/main/crash-reporting/crashpad-capture.ts b/src/main/crash-reporting/crashpad-capture.ts index 9e2336babfc1..52b970eb1161 100644 --- a/src/main/crash-reporting/crashpad-capture.ts +++ b/src/main/crash-reporting/crashpad-capture.ts @@ -11,6 +11,7 @@ import type { Dirent } from 'node:fs' import { open, readdir, rm, stat } from 'node:fs/promises' import path from 'node:path' import { app, crashReporter } from 'electron' +import { installAppImageCrashpadHandlerExit } from './appimage-crashpad-handler-exit' import { createMinidumpFileSource, observeMinidumpExtent } from './minidump-file-source' import { parseMinidumpCrashSignature, @@ -79,6 +80,7 @@ export function startCrashpadCapture(options: CrashpadCaptureOptions = {}): bool console.error('[crash-reporting] Crashpad start failed:', error) return false } + installAppImageCrashpadHandlerExit() crashpadDumpDirectory = options.dumpDirectory ?? resolveDumpDirectory() // Why: a dying main process never delivers process-gone, so a crash loop // never reaches the post-crash prune, and Crashpad's own pass runs in the From 92e204fdac17eee416af37ead3b67964596ab398 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:29:47 -0700 Subject: [PATCH 39/58] test(ssh): Windows host lane drops relay cells (#27421) The relay client is gone, so the three relay-ladder cells and the @orcad-cli-relay-kept matrix case could not run. Delete them, their last helper (ssh-test-target-connection), a stale path filter and the unused build:relay step; rename the runner to invoke-windows-host-cells.ps1. Co-authored-by: m4air --- .github/workflows/ssh-windows-hosts.yml | 20 +- ...ells.ps1 => invoke-windows-host-cells.ps1} | 23 +- .../preview-ssh/test-preview-diagnostics.ps1 | 2 +- src/main/ssh/orcad-windows-host-lane.test.ts | 13 +- src/main/ssh/ssh-windows-host-cells.test.ts | 51 ++--- src/main/ssh/ssh-windows-host-cells.ts | 80 +------ tests/e2e/helpers/orcad-convert-flow.ts | 2 +- .../e2e/helpers/ssh-test-target-connection.ts | 168 --------------- .../e2e/ssh-orcad-windows-cli-matrix.spec.ts | 196 +----------------- 9 files changed, 51 insertions(+), 504 deletions(-) rename config/ci/windows-ssh-provider/{invoke-pinned-relay-cells.ps1 => invoke-windows-host-cells.ps1} (85%) delete mode 100644 tests/e2e/helpers/ssh-test-target-connection.ts diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 9cadb3a8b853..9da215290278 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -1,10 +1,9 @@ name: SSH Windows hosts -# Design D5/D6 exit gate, Windows half: the real client-side relay deploy against a real -# Win32-OpenSSH server on 127.0.0.1 (inbox capability and the pinned 10.0.0.0p2-Preview release), -# on x64 and arm64. Each job provisions a private sshd service and one standard account per cell -# (config/ci/windows-ssh-provider/), hides the host toolchain from SSH sessions behind logging -# shims, and asserts rung A on the pinned node.exe in both DefaultShell cases plus the opt-out. +# Managed orcad against a real Win32-OpenSSH server on 127.0.0.1 (inbox capability and the pinned +# 10.0.0.0p2-Preview release), on x64 and arm64. Each job provisions a private sshd service and one +# standard account per cell (config/ci/windows-ssh-provider/), hides the host toolchain from SSH +# sessions behind logging shims, and deploys orcad on the pinned node.exe in both DefaultShell cases. on: pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -30,13 +29,11 @@ on: - 'native/windows-cli-launcher/**' - 'src/main/orcad/orcad-cli-launcher.ts' - '.github/actions/prepare-windows-cli-launcher/**' - - 'config/scripts/build-relay.mjs' - 'config/patches/node-pty*' - 'config/patches/@vscode__windows-process-tree*' - 'config/scripts/build-windows-process-tree-relay-addon.mjs' - 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs' - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' - - 'src/shared/relay-windows-breakaway-launch.ts' - 'src/shared/windows-breakaway-launch*.ts' - 'src/main/ipc/ssh-host-server-*.ts' - '!src/**/*.test.ts' @@ -136,8 +133,8 @@ jobs: native-runtime: node cache-pnpm-store: 'false' # The deploy materializes rung A from this template; only this runner's slot exists here. - # The process-tree addon carries the launcher that starts the relay outside sshd's job; the - # orcad slot and the relay both stage it, and a standard-user host has no other launch route. + # The process-tree addon carries the launcher that starts orcad outside sshd's job; the orcad + # slot stages it, and a standard-user host has no other launch route. - name: Build this runner's Windows process-table addon shell: bash env: @@ -155,12 +152,11 @@ jobs: - uses: ./.github/actions/prepare-windows-cli-launcher with: arches: ${{ matrix.arch }} - - name: Build the win32 template and the relay + - name: Build the win32 template shell: bash run: | pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" - pnpm run build:relay - wait: inbox-capability - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh @@ -190,7 +186,7 @@ jobs: if($LASTEXITCODE -ne 0){throw 'SSH archive fetch failed'} } $callback={param($context) - & (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells + & (Join-Path $tools 'invoke-windows-host-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells }.GetNewClosure() & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -ForwardingAccounts $forwarding -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') - uses: actions/upload-artifact@v7 diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 similarity index 85% rename from config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 rename to config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 index d9ebbf1f19d4..13d18b02ae98 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 @@ -6,13 +6,13 @@ param( [Parameter(Mandatory=$true)][hashtable]$Context, [Parameter(Mandatory=$true)][ValidateSet('win32-arm64','win32-x64')][string]$Target, [Parameter(Mandatory=$true)][string]$ReceiptRoot, - [ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell','orcad-convert','orcad-cli-managed','orcad-cli-convert','orcad-cli-relay-kept')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell') + [ValidateSet('orcad-cmd','orcad-powershell','orcad-convert','orcad-cli-managed','orcad-cli-convert')][string[]]$Cells=@('orcad-cmd','orcad-powershell') ) $ErrorActionPreference='Stop' if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1'){throw 'Disposable CI only'} -$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd';'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-convert'='cmd';'orcad-cli-managed'='cmd';'orcad-cli-convert'='cmd';'orcad-cli-relay-kept'='cmd'} +$shells=@{'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-convert'='cmd';'orcad-cli-managed'='cmd';'orcad-cli-convert'='cmd'} # App-level cells drive the e2e build (and the bundled CLI) against the host; each greps one tagged test. -$appCells=@{'orcad-convert'=@('tests/e2e/ssh-orcad-auto-convert.spec.ts','');'orcad-cli-managed'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-managed');'orcad-cli-convert'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-convert');'orcad-cli-relay-kept'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-relay-kept')} +$appCells=@{'orcad-convert'=@('tests/e2e/ssh-orcad-auto-convert.spec.ts','');'orcad-cli-managed'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-managed');'orcad-cli-convert'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-convert')} $electronBuilt=$false if($Context.accounts.Count -lt $Cells.Count){throw 'Each cell needs its own private account'} $seenApp=$false @@ -48,9 +48,9 @@ function Set-PrivateDefaultShell([string]$Shell) { } } -# Diagnostic only: Orca must launch the relay without WMI, which refuses a standard user's SSH +# Diagnostic only: Orca must launch orcad without WMI, which refuses a standard user's SSH # (network) logon unless an administrator grants Remote Enable on root\cimv2. The cells run with no -# such grant, so a relay launch that still needs WMI fails its cell. Prints ORCA_WMI= +# such grant, so a launch that still needs WMI fails its cell. Prints ORCA_WMI= # or ORCA_WMI=denied. function Test-PrivateWmiLaunch([string]$Account) { $out=Invoke-PrivateSsh $Account 'powershell.exe -NoProfile -NonInteractive -Command "try{$r=Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine=''cmd.exe /d /c exit 0''} -ErrorAction Stop;''ORCA_WMI=''+$r.ReturnValue}catch{''ORCA_WMI=denied''}"' @@ -99,7 +99,7 @@ try { $env:ORCA_BACKGROUND_LAUNCH='1' # DefaultShell is still stock cmd here. $wmi=Test-PrivateWmiLaunch $Context.accounts[0].name - Write-Host "Standard SSH user WMI Win32_Process.Create: $wmi (no grant; the relay launch must not depend on it)" + Write-Host "Standard SSH user WMI Win32_Process.Create: $wmi (no grant; the orcad launch must not depend on it)" $summary.Add(@{standardUserWmiLaunch=$wmi;granted=$false}) for($index=0;$index -lt $Cells.Count;$index++){ $cell=$Cells[$index];$account=$Context.accounts[$index];$shell=$shells[$cell] @@ -122,21 +122,20 @@ try { # Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy. $code=$global:LASTEXITCODE } - # The relay's own log is the only record of why it closed a client. # orcad.log holds only the last launch on Windows; orcad.log.1 is the one before a restart. - foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log.1') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")} + foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\orcad-*\orcad.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log.1') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")} if(Test-Path -LiteralPath $Context.forbiddenToolLog){Copy-Item -LiteralPath $Context.forbiddenToolLog -Destination (Join-Path $ReceiptRoot "$cell.forbidden-tool-calls.log")} $summary.Add(@{cell=$cell;shell=$shell;account=$account.name;exitCode=$code}) if($code -ne 0){$failed.Add($cell)} } - # Relays outlive their client by a 60s grace; wait so cleanup can unload the profiles. + # Host processes can outlive their client; wait so cleanup can unload the profiles. $homes=@($Context.accounts | ForEach-Object {$_.home.TrimEnd('\')+'\'}) $graceDeadline=[DateTime]::UtcNow.AddSeconds(120) do { - $relays=@(Get-CimInstance Win32_Process | Where-Object {$path=$_.ExecutablePath;$path -and @($homes | Where-Object {$path.StartsWith($_,[StringComparison]::OrdinalIgnoreCase)}).Count}) - if(-not $relays.Count){break};Start-Sleep -Seconds 2 + $hostProcesses=@(Get-CimInstance Win32_Process | Where-Object {$path=$_.ExecutablePath;$path -and @($homes | Where-Object {$path.StartsWith($_,[StringComparison]::OrdinalIgnoreCase)}).Count}) + if(-not $hostProcesses.Count){break};Start-Sleep -Seconds 2 } while([DateTime]::UtcNow -lt $graceDeadline) - $summary.Add(@{relayProcessesAfterGrace=@($relays | ForEach-Object {[IO.Path]::GetFileName($_.ExecutablePath)})}) + $summary.Add(@{hostProcessesAfterGrace=@($hostProcesses | ForEach-Object {[IO.Path]::GetFileName($_.ExecutablePath)})}) } finally { Set-PrivateDefaultShell 'cmd' if($knownExisted){[IO.File]::WriteAllBytes($knownPath,$priorKnown)}else{Remove-Item -LiteralPath $knownPath -Force -ErrorAction SilentlyContinue} diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 5bd3ccf30cdc..088534f7be50 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -3,7 +3,7 @@ $errors=$null;$tokens=$null $ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'prove-preview-openssh.ps1'),[ref]$tokens,[ref]$errors) if($errors.Count){throw 'Fixture failed to parse'} # Script-scope assignments share one case-insensitive namespace with typed params: $accounts rebinds [int]$Accounts. -foreach($script in @($ast,[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../invoke-pinned-relay-cells.ps1'),[ref]$null,[ref]$null))){ +foreach($script in @($ast,[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../invoke-windows-host-cells.ps1'),[ref]$null,[ref]$null))){ $params=@($script.ParamBlock.Parameters | ForEach-Object {$_.Name.VariablePath.UserPath}) $shadows=@($script.FindAll({param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left -is [Management.Automation.Language.VariableExpressionAst]},$true) | Where-Object { $parent=$_.Parent;while($parent -and $parent -isnot [Management.Automation.Language.FunctionDefinitionAst] -and $parent -isnot [Management.Automation.Language.ScriptBlockExpressionAst]){$parent=$parent.Parent} diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index 3067816ae644..df02ed2fea0f 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -1,8 +1,9 @@ // Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context // (pinned node.exe, host script), deploy and activate, prove readiness and liveness, reach orcad -// through the stdio bridge (this account's sshd refuses forwarding), decommission -// through the instance-bound stop request, prove exit, and run a GC pass. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 -// provisions the account and runs this file for `orcad-*` cells; ssh-windows-hosts.yml runs that. +// through the stdio bridge (this account's sshd refuses forwarding), decommission through the +// instance-bound stop request, prove exit, and run a GC pass. +// config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 provisions the account and runs this +// file for `orcad-*` cells; ssh-windows-hosts.yml runs that. // // Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL= pnpm test import { randomUUID } from 'node:crypto' @@ -59,11 +60,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { if (!isWindowsOrcadCellId(descriptor.cell)) { throw new Error(`${descriptor.cell} is not a managed orcad cell`) } - const sshTarget = windowsHostSshTarget( - descriptor, - { id: descriptor.cell, remoteRuntime: 'pinned-node' }, - randomUUID() - ) + const sshTarget = windowsHostSshTarget(descriptor, randomUUID()) let exec: MockInstance | null = null const receipt: Record = { cell: descriptor.cell, target: descriptor.target } let conn: SshConnection | null = null diff --git a/src/main/ssh/ssh-windows-host-cells.test.ts b/src/main/ssh/ssh-windows-host-cells.test.ts index f184b461ec95..9d135c5b27db 100644 --- a/src/main/ssh/ssh-windows-host-cells.test.ts +++ b/src/main/ssh/ssh-windows-host-cells.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - isWindowsHostCellId, - parseWindowsHostCellDescriptor, - WINDOWS_FORBIDDEN_TOOLS, - WINDOWS_HOST_CELL_IDS, - windowsHostCell, - windowsHostSshTarget -} from './ssh-windows-host-cells' +import { parseWindowsHostCellDescriptor, windowsHostSshTarget } from './ssh-windows-host-cells' const DESCRIPTOR = { - cell: 'pinned-powershell', + cell: 'orcad-powershell', target: 'win32-arm64', host: '127.0.0.1', port: 50022, @@ -17,38 +10,15 @@ const DESCRIPTOR = { identityFile: 'C:\\ossh\\client_key', home: 'C:/Users/orcaabc', forbiddenToolLog: 'C:\\ossh\\forbidden.log', - receipt: 'C:\\receipts\\pinned-powershell.json' + receipt: 'C:\\receipts\\orcad-powershell.json' } describe('Windows SSH-host cells', () => { - it('pairs each relay cell with its DefaultShell and remote runtime', () => { - const cells = WINDOWS_HOST_CELL_IDS.map((id) => windowsHostCell(id)) - expect( - cells.map(({ id, defaultShell, remoteRuntime }) => [id, defaultShell, remoteRuntime]) - ).toEqual([ - ['pinned-cmd', 'cmd', 'pinned-node'], - ['pinned-powershell', 'powershell', 'pinned-node'], - ['legacy-opt-out', 'cmd', 'legacy'] - ]) - }) - - it('shims the Windows compilers alongside the Linux toolchain list', () => { - expect(WINDOWS_FORBIDDEN_TOOLS).toEqual( - expect.arrayContaining(['npm', 'node-gyp', 'cl', 'clang']) - ) - // Why never node: the lane proves host Node is absent from PATH, not merely logged. - expect(WINDOWS_FORBIDDEN_TOOLS).not.toContain('node') - }) - it('reads the descriptor PowerShell writes, BOM included', () => { const parsed = parseWindowsHostCellDescriptor(`\uFEFF${JSON.stringify(DESCRIPTOR)}`) expect(parsed).toEqual(DESCRIPTOR) - if (!isWindowsHostCellId(parsed.cell)) { - throw new Error(`expected a relay cell, got ${parsed.cell}`) - } - const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell), 'r1') - expect(target).toMatchObject({ - id: 'windows-host-pinned-powershell-r1', + expect(windowsHostSshTarget(parsed, 'r1')).toMatchObject({ + id: 'windows-host-orcad-powershell-r1', host: '127.0.0.1', port: 50022, username: 'orcaabc', @@ -61,10 +31,13 @@ describe('Windows SSH-host cells', () => { it('rejects a descriptor that would drive the wrong host or cell', () => { const parse = (patch: Record): unknown => parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, ...patch })) - expect(() => parse({ cell: 'pinned-bash' })).toThrow('Unknown Windows host cell') - expect( - parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, cell: 'orcad-cmd' })).cell - ).toBe('orcad-cmd') + expect(() => parse({ cell: 'pinned-cmd' })).toThrow('Unknown Windows host cell') + expect(() => parse({ cell: 'orcad-cli-relay-kept' })).toThrow('Unknown Windows host cell') + for (const cell of ['orcad-cmd', 'orcad-convert', 'orcad-cli-managed', 'orcad-cli-convert']) { + expect(parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, cell })).cell).toBe( + cell + ) + } expect(() => parse({ target: 'linux-x64-glibc' })).toThrow('win32-x64 or win32-arm64') expect(() => parse({ port: '22' })).toThrow('port is invalid') expect(() => parse({ port: 70_000 })).toThrow('port is invalid') diff --git a/src/main/ssh/ssh-windows-host-cells.ts b/src/main/ssh/ssh-windows-host-cells.ts index 9b6032dbb3df..45634e480c4e 100644 --- a/src/main/ssh/ssh-windows-host-cells.ts +++ b/src/main/ssh/ssh-windows-host-cells.ts @@ -1,62 +1,10 @@ /** - * Windows SSH-host lanes for the pinned relay (design D5/D6 exit gate): a real Win32-OpenSSH - * server on 127.0.0.1, provisioned by config/ci/windows-ssh-provider/, driven through the same - * harness as the Docker hostile-host matrix. `.github/workflows/ssh-windows-hosts.yml` runs it. + * Windows SSH-host cells for managed orcad: a real Win32-OpenSSH server on 127.0.0.1, provisioned + * by config/ci/windows-ssh-provider/. `.github/workflows/ssh-windows-hosts.yml` runs them. */ import { readFileSync } from 'node:fs' import { isWindowsServerTarget, type WindowsServerTarget } from '../../shared/node-runtime-pin' -import type { SshRemoteRuntime, SshTarget } from '../../shared/ssh-types' -import { FORBIDDEN_TOOLS } from './ssh-hostile-host-cells' - -/** `cmd` is stock sshd (no DefaultShell value); `powershell` sets DefaultShell to Windows PowerShell. */ -export const WINDOWS_SSH_DEFAULT_SHELLS = ['cmd', 'powershell'] as const -export type WindowsSshDefaultShell = (typeof WINDOWS_SSH_DEFAULT_SHELLS)[number] - -/** Shimmed on the SSH-side PATH as logging `.cmd` files; the Linux list plus MSVC and LLVM. */ -export const WINDOWS_FORBIDDEN_TOOLS = [ - ...FORBIDDEN_TOOLS, - 'cl', - 'clang', - 'clang++', - 'msbuild', - 'cmake' -] as const - -export type WindowsHostCell = { - id: string - defaultShell: WindowsSshDefaultShell - remoteRuntime: SshRemoteRuntime -} - -export const WINDOWS_HOST_CELL_IDS = ['pinned-cmd', 'pinned-powershell', 'legacy-opt-out'] as const -export type WindowsHostCellId = (typeof WINDOWS_HOST_CELL_IDS)[number] - -export function isWindowsHostCellId(id: string): id is WindowsHostCellId { - return WINDOWS_HOST_CELL_IDS.some((candidate) => candidate === id) -} - -export function windowsHostCell(id: WindowsHostCellId): WindowsHostCell { - switch (id) { - case 'pinned-cmd': - return { - id, - defaultShell: 'cmd', - remoteRuntime: 'pinned-node' - } - case 'pinned-powershell': - return { - id, - defaultShell: 'powershell', - remoteRuntime: 'pinned-node' - } - case 'legacy-opt-out': - return { - id, - defaultShell: 'cmd', - remoteRuntime: 'legacy' - } - } -} +import type { SshTarget } from '../../shared/ssh-types' /** Managed orcad on the pinned node.exe, per DefaultShell: deploy, readiness, stop request, exit. */ export const WINDOWS_ORCAD_CELL_IDS = ['orcad-cmd', 'orcad-powershell'] as const @@ -70,20 +18,12 @@ export function isWindowsOrcadCellId(id: string): id is WindowsOrcadCellId { export const WINDOWS_CONVERT_CELL_ID = 'orcad-convert' /** The bundled CLI against a managed host (tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts). */ -export const WINDOWS_CLI_MATRIX_CELL_IDS = [ - 'orcad-cli-managed', - 'orcad-cli-convert', - 'orcad-cli-relay-kept' -] as const +export const WINDOWS_CLI_MATRIX_CELL_IDS = ['orcad-cli-managed', 'orcad-cli-convert'] as const export type WindowsCliMatrixCellId = (typeof WINDOWS_CLI_MATRIX_CELL_IDS)[number] -/** Written by config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1, one per run. */ +/** Written by config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1, one per run. */ export type WindowsHostCellDescriptor = { - cell: - | WindowsHostCellId - | WindowsOrcadCellId - | typeof WINDOWS_CONVERT_CELL_ID - | WindowsCliMatrixCellId + cell: WindowsOrcadCellId | typeof WINDOWS_CONVERT_CELL_ID | WindowsCliMatrixCellId target: WindowsServerTarget host: string port: number @@ -110,7 +50,6 @@ export function parseWindowsHostCellDescriptor(text: string): WindowsHostCellDes } const record = Object.fromEntries(Object.entries(parsed)) const cell = - WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell) ?? WINDOWS_ORCAD_CELL_IDS.find((id) => id === record.cell) ?? WINDOWS_CLI_MATRIX_CELL_IDS.find((id) => id === record.cell) ?? (record.cell === WINDOWS_CONVERT_CELL_ID ? WINDOWS_CONVERT_CELL_ID : undefined) @@ -144,18 +83,17 @@ export function readWindowsHostCellDescriptor(path: string): WindowsHostCellDesc export function windowsHostSshTarget( descriptor: WindowsHostCellDescriptor, - cell: Pick, runId: string ): SshTarget { return { - id: `windows-host-${cell.id}-${runId}`, - label: `Windows host ${cell.id}`, + id: `windows-host-${descriptor.cell}-${runId}`, + label: `Windows host ${descriptor.cell}`, source: 'manual', host: descriptor.host, port: descriptor.port, username: descriptor.username, identityFile: descriptor.identityFile, identitiesOnly: true, - remoteRuntime: cell.remoteRuntime + remoteRuntime: 'pinned-node' } } diff --git a/tests/e2e/helpers/orcad-convert-flow.ts b/tests/e2e/helpers/orcad-convert-flow.ts index 9023bcd5298a..c2f83a7f6dff 100644 --- a/tests/e2e/helpers/orcad-convert-flow.ts +++ b/tests/e2e/helpers/orcad-convert-flow.ts @@ -82,7 +82,7 @@ function sourceRows( return { repos: count(state.repos), folderWorkspaces: count(state.folderWorkspaces) } } -export function targetLeases(userData: string, targetId: string): { state?: unknown }[] { +function targetLeases(userData: string, targetId: string): { state?: unknown }[] { const leases = readPersistedProfileState(userData).sshRemotePtyLeases return (Array.isArray(leases) ? leases : []).filter((lease) => lease?.targetId === targetId) } diff --git a/tests/e2e/helpers/ssh-test-target-connection.ts b/tests/e2e/helpers/ssh-test-target-connection.ts deleted file mode 100644 index 3c241ffe6a90..000000000000 --- a/tests/e2e/helpers/ssh-test-target-connection.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' -import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' - -export type ConnectedSshTestTarget = { - targetId: string - repoId: string - worktreeId: string -} - -type SshTestConnectionOptions = { - remotePath: string - displayName: string - seedInitialTab?: boolean - /** A target already in the profile (a seeded one): connect it instead of adding `target`. */ - existingTargetId?: string -} - -export async function connectSshTestTarget( - page: Page, - target: SshTargetCreateInput, - options: SshTestConnectionOptions -): Promise { - return page.evaluate( - async ({ target, remotePath, displayName, seedInitialTab, existingTargetId }) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { - void window.api.ssh.submitCredential({ - requestId: request.requestId, - value: null - }) - }) - try { - const createdTarget = existingTargetId - ? (await window.api.ssh.listTargets()).find((entry) => entry.id === existingTargetId) - : await window.api.ssh.addTarget({ target }).then((added) => { - store.getState().recordSshRepoReadoptions(added.repoReadoptions) - return added.target - }) - if (!createdTarget) { - throw new Error(`No SSH target ${existingTargetId} in the profile`) - } - const state = await window.api.ssh.connect({ - targetId: createdTarget.id - }) - 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, - remotePath, - displayName - }) - 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({ runtimeEnvironmentId: null, throwOnError: true }) - 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}`) - } - store.getState().setActiveWorktree(worktree.id) - if (seedInitialTab && (store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { - store.getState().createTab(worktree.id) - } - store.getState().setActiveTabType('terminal', store.getState().activeWorktreeId) - return { - targetId: createdTarget.id, - repoId: result.repo.id, - worktreeId: worktree.id - } - } finally { - credentialUnsub() - } - }, - { - target, - remotePath: options.remotePath, - displayName: options.displayName, - seedInitialTab: options.seedInitialTab ?? true, - existingTargetId: options.existingTargetId - } - ) -} diff --git a/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts b/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts index 62b9d5cb82c3..c781045608b8 100644 --- a/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts +++ b/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts @@ -1,6 +1,6 @@ /** * The bundled `orca` CLI against a real Windows OpenSSH host running managed orcad, one cell per - * test (config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 greps by tag): + * test (config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 greps by tag): * * - `@orcad-cli-managed`: an empty host deploys; `host list`/`environment list|show` name it; a CLI * terminal survives disconnect/reconnect; `environment rm` refuses a managed server, the stop @@ -8,8 +8,6 @@ * reads unverifiable then live, never retired. * - `@orcad-cli-convert`: a seeded relay-era profile converts, keeps then retires its source, and the * CLI reaches the converted server's projects. - * - `@orcad-cli-relay-kept`: an open relay terminal keeps the host on the relay with its status line; - * once the CLI closes it, the next connect converts. * * Host: `ORCA_E2E_ORCAD_CONVERT_HOST`, a Windows host-cell descriptor. Template: * `ORCA_E2E_ORCAD_CONVERT_TEMPLATE`, built for that host's target. @@ -19,19 +17,11 @@ import os from 'node:os' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' -import { execInTerminal, waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' -import { connectSshTestTarget } from './helpers/ssh-test-target-connection' +import { waitForSessionReady } from './helpers/store' import { createRestartSession } from './helpers/orca-restart' -import { - convertThenRetire, - managedServer, - reconnect, - targetLeases -} from './helpers/orcad-convert-flow' -import { seedRelayEraProfile, seedRelayEraTarget } from './helpers/orcad-upgrade-profile' +import { convertThenRetire, managedServer, reconnect } from './helpers/orcad-convert-flow' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' import { ORCAD_CONVERT_HOST_ENV, startOrcadConvertHost } from './helpers/orcad-convert-host' -import { mutateStoppedProfileState } from './helpers/persisted-profile-state' import { orcaCliResult, runCompiledOrcaCli } from './helpers/compiled-orca-cli' import { killHostOrcad, @@ -95,25 +85,6 @@ async function disposeQuietly(session: { dispose: () => Promise }): Promis } } -/** Windows can keep the just-closed app's SQLite file busy for a moment ("disk I/O error"). */ -async function mutateStoppedProfileStateWhenReleased( - userDataDir: string, - mutate: (state: Record) => void -): Promise { - for (let attempt = 1; ; attempt++) { - try { - mutateStoppedProfileState(userDataDir, mutate) - return - } catch (error) { - if (attempt >= 10) { - throw error - } - console.log(`[cli-matrix] stopped profile busy (attempt ${attempt}): ${String(error)}`) - await new Promise((resolve) => setTimeout(resolve, 1_000)) - } - } -} - async function waitForManaged(page: Page, targetId: string): Promise { let environmentId = '' await expect @@ -619,162 +590,3 @@ test('@orcad-cli-convert a seeded relay-era profile converts its Windows host, k rmSync(SCRATCH, { recursive: true, force: true }) } }) - -test('@orcad-cli-relay-kept an open relay terminal keeps a Windows host on the relay until the CLI closes it', async (// oxlint-disable-next-line no-empty-pattern -- This test owns its launches through a restart session. -{}, testInfo) => { - skipUnlessWindowsHost() - test.setTimeout(30 * 60_000) - resetScratch() - const host = startOrcadConvertHost(HOST!, testInfo) - // Long enough that the relay, and its terminal, outlive the app between launches. - // Pinned node: the lane hides host Node, so the default runtime can't start a relay here. - const input = { - ...host.input, - relayGracePeriodSeconds: 900, - remoteRuntime: 'pinned-node' as const - } - const session = createRestartSession(testInfo, launchEnv()) - let app: ElectronApplication | null = null - try { - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - // Why retried: Playwright can drop the first main-process evaluate on a cold Windows launch. - const readVersion = (): Promise => - first.app.evaluate(({ app: electronApp }) => electronApp.getVersion()) - const appVersion = await readVersion().catch(readVersion) - await session.close(app) - app = null - // Why a recorded "can't run": the Windows relay needs the template that would otherwise convert. - const targetId = seedRelayEraTarget(session.userDataDir, input, { - managedServerUnavailable: { reason: 'e2e_relay_era', appVersion } - }) - - // Relay era: one shell running on the relay. - const relayEra = await session.launch({ onStderr: sshStderr }) - app = relayEra.app - await waitForSessionReady(relayEra.page) - const remote = await connectSshTestTarget(relayEra.page, input, { - remotePath: host.remoteRepoPath, - displayName: 'orcad cli relay-kept E2E', - seedInitialTab: true, - existingTargetId: targetId - }) - expect(await managedServer(relayEra.page, targetId)).toMatchObject({ - kind: 'relay', - reason: 'orcad_unavailable' - }) - await ensureTerminalVisible(relayEra.page, 45_000) - const ptyId = await waitForActivePanePtyId(relayEra.page, 60_000) - const marker = `ORCA_RELAY_KEPT_${Date.now()}` - await execInTerminal(relayEra.page, ptyId, `echo ${marker}`) - await waitForTerminalOutput(relayEra.page, marker, 30_000) - const localTerminals = await orcaCliResult(session.userDataDir, [ - 'terminal', - 'list', - '--worktree', - `id:${remote.worktreeId}` - ]) - expect(localTerminals.terminals.length, 'the CLI lists the relay terminal').toBeGreaterThan(0) - await session.close(app) - app = null - await mutateStoppedProfileStateWhenReleased(session.userDataDir, (state) => { - const targets = Array.isArray(state.sshTargets) ? state.sshTargets : [] - for (const target of targets) { - if (target?.id === targetId) { - delete target.managedServerUnavailable - } - } - }) - - // Converting build with the relay terminal still open: the host stays on the relay. - const kept = await session.launch({ onStderr: sshStderr }) - app = kept.app - const page = kept.page - await waitForSessionReady(page) - testInfo.annotations.push({ - type: 'kept-connect', - description: await reconnect(page, targetId) - }) - await expect - .poll(async () => JSON.stringify(await managedServer(page, targetId)), { - timeout: 120_000 - }) - .toContain('relay_terminals_live') - const status = await managedServer(page, targetId) - console.log(`[cli-matrix] relay-kept census: ${JSON.stringify(status)}`) - expect(status).toMatchObject({ kind: 'relay', reason: 'relay_terminals_live' }) - const count = - status && - typeof status === 'object' && - 'terminals' in status && - typeof status.terminals === 'number' - ? status.terminals - : 0 - expect(count).toBeGreaterThan(0) - await page.evaluate(() => { - const state = window.__store!.getState() - state.openSettingsTarget({ pane: 'ssh', repoId: null }) - state.openSettingsPage() - }) - const section = page.locator('[data-settings-section="ssh"]') - await expect - .poll(async () => ((await section.count()) ? await section.innerText() : ''), { - timeout: 30_000 - }) - .toMatch(new RegExp(`Runs the relay until its ${count} open terminals? (is|are) closed`, 'u')) - expect((await hostRows(session.userDataDir)).find((row) => row.id === targetId)).toMatchObject({ - kind: 'ssh', - connected: true - }) - - // The CLI closes the relay terminals; with none left, the next connect converts. - // The close must confirm the relay PTY's exit (#25304); its timing tells a lagging record apart. - const closeStartedAt = Date.now() - const closed = await runCompiledOrcaCli(session.userDataDir, [ - 'terminal', - 'close', - '--worktree', - `id:${remote.worktreeId}`, - '--all', - '--json' - ]) - console.log(`[cli-matrix] relay close took ${Date.now() - closeStartedAt}ms: ${closed.stdout}`) - expect(closed.json?.ok, closed.stdout).toBe(true) - await expect - .poll( - async () => - JSON.stringify({ - sessions: await page.evaluate( - (connectionId) => window.api.pty.listSessions({ connectionId }), - targetId - ), - leases: targetLeases(session.userDataDir, targetId).filter( - (lease) => lease.state === 'attached' || lease.state === 'detached' - ) - }), - { timeout: 60_000 } - ) - .toBe(JSON.stringify({ sessions: [], leases: [] })) - testInfo.annotations.push({ - type: 'convert-connect', - description: await reconnect(page, targetId) - }) - const environmentId = await waitForManaged(page, targetId) - expect(await environmentIds(session.userDataDir)).toContain(environmentId) - const repos = await orcaCliResult<{ repos: { path: string }[] }>(session.userDataDir, [ - 'repo', - 'list', - '--environment', - environmentId - ]) - expect(repos.repos.some((repo) => samePath(repo.path, host.remoteRepoPath))).toBe(true) - } finally { - if (app) { - await session.close(app) - } - await disposeQuietly(session) - host.cleanup() - rmSync(SCRATCH, { recursive: true, force: true }) - } -}) From f8ea867124f27b6e3d30950038de4da231f729ef Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:34:46 -0700 Subject: [PATCH 40/58] feat(daemon): observe-only fork gate for automatic terminal restores (#27424) * feat(daemon): observe-only fork gate for automatic terminal restores * fix(daemon): keep a canceled fork charged until reaped; never strand a user resume --------- Co-authored-by: m4air --- src/main/daemon/daemon-server.ts | 2 + src/main/daemon/daemon-terminal-admission.ts | 16 ++ .../terminal-host-agent-session-claim.ts | 3 + src/main/daemon/terminal-host-options.ts | 3 + .../daemon/terminal-host-session-create.ts | 31 ++- src/main/daemon/terminal-host.ts | 7 +- src/main/daemon/terminal-spawn-gate.test.ts | 176 +++++++++++++ src/main/daemon/terminal-spawn-gate.ts | 152 +++++++++++ src/shared/host-spawn-admission.test.ts | 215 ++++++++++++++++ src/shared/host-spawn-admission.ts | 241 ++++++++++++++++++ 10 files changed, 839 insertions(+), 7 deletions(-) create mode 100644 src/main/daemon/terminal-spawn-gate.test.ts create mode 100644 src/main/daemon/terminal-spawn-gate.ts create mode 100644 src/shared/host-spawn-admission.test.ts create mode 100644 src/shared/host-spawn-admission.ts diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 02be19b439e7..7ee6805bad72 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -21,6 +21,7 @@ import { DaemonTerminalAdmission } from './daemon-terminal-admission' import { checkPtySpawnHealth } from './pty-subprocess' import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import { TerminalHost } from './terminal-host' +import { TerminalSpawnGate } from './terminal-spawn-gate' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -50,6 +51,7 @@ export class DaemonServer { this.log = options.log ?? createNoopDaemonFileLog() this.host = new TerminalHost({ spawnSubprocess: options.spawnSubprocess, + spawnGate: new TerminalSpawnGate({ log: (event, details) => this.log.log(event, details) }), reportReadinessEvent: (event, details) => this.log.log(event, details), onSessionReaped: (sessionId) => { this.attachments.release(sessionId) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index f02cbdb9205c..950592958e5e 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -23,6 +23,17 @@ import { type DaemonRequest, TerminalAttachCanceledError } from './types' type CreateOrAttachRequest = Extract +const DEFAULT_SPAWN_ADMISSION_WINDOW_MS = 15_000 + +// Why half: a paced fork must still finish before the caller's own cancellation fires. +function resolveSpawnAdmissionDeadline(cancelAfterMs: unknown): number { + const windowMs = + Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0 + ? Math.min(Number(cancelAfterMs), 300_000) / 2 + : DEFAULT_SPAWN_ADMISSION_WINDOW_MS + return Date.now() + windowMs +} + type DaemonTerminalAdmissionOptions = { host: TerminalHost connections: DaemonClientConnections @@ -115,6 +126,11 @@ export class DaemonTerminalAdmission { ? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs } : {}), ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), + spawnAdmission: { + clientId, + declaredIntent: null, + deadlineAt: resolveSpawnAdmissionDeadline(payload.cancelAfterMs) + }, isCanceled: () => spawnPreparation?.canceled === true, cancelSignal: spawnPreparation.controller.signal, onSessionResolved: (sessionId) => { diff --git a/src/main/daemon/terminal-host-agent-session-claim.ts b/src/main/daemon/terminal-host-agent-session-claim.ts index 54251e40df25..84ee3830d9bb 100644 --- a/src/main/daemon/terminal-host-agent-session-claim.ts +++ b/src/main/daemon/terminal-host-agent-session-claim.ts @@ -1,9 +1,12 @@ import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' export type InternalCreateOrAttachOptions = CreateOrAttachOptions & { agentSessionGeneration?: string + /** Request identity for the fork gate; absent for in-process callers. */ + spawnAdmission?: { clientId: string; declaredIntent: SpawnIntent | null; deadlineAt: number } isCanceled?: () => boolean cancelSignal?: AbortSignal } diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index 918628b322b5..a970cc71c590 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -2,6 +2,7 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery import type { TuiAgent } from '../../shared/tui-agent' import type { SubprocessHandle } from './session-subprocess-handle' import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import type { TerminalSpawnGate } from './terminal-spawn-gate' export type TerminalHostOptions = { spawnSubprocess: (opts: { @@ -34,6 +35,8 @@ export type TerminalHostOptions = { snapshot: TerminalSnapshot, records: TakePendingOutputResult['records'] ) => void + /** Charged only on the branch that forks; attach and adoption never reach it. */ + spawnGate?: TerminalSpawnGate // Why: tests need deterministic tombstone eviction without thousands of sessions. maxTombstones?: number } diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 9b10ec036021..35e2dd765ac4 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -16,6 +16,7 @@ import type { CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' import type { TerminalHostTombstones } from './terminal-host-tombstones' import type { TerminalSessionTeardown } from './terminal-session-teardown' +import type { TerminalSpawnGate } from './terminal-spawn-gate' import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window' import { TerminalAttachCanceledError } from './daemon-errors' import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' @@ -29,6 +30,7 @@ type TerminalHostSessionCreateDependencies = { sessionTeardown: TerminalSessionTeardown killedTombstones: TerminalHostTombstones spawnSubprocess: TerminalHostOptions['spawnSubprocess'] + spawnGate: TerminalSpawnGate | undefined onDeadSessionRemoved: (sessionId: string) => void onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void onSessionExit: (sessionId: string, generation: string | undefined) => void @@ -107,13 +109,27 @@ export async function createOrAttachTerminalSession( deps.killedTombstones.clearForCreate(opts.sessionId) const size = normalizePtySize(opts.cols, opts.rows) const wslDistro = resolveWslSessionContext(opts)?.distro - return await spawnAndPublishSession(opts, deps, { size, wslDistro }) + const lease = deps.spawnGate?.admit({ + clientId: opts.spawnAdmission?.clientId ?? 'in-process', + declaredIntent: opts.spawnAdmission?.declaredIntent ?? null, + hasAgentSessionClaim: opts.agentSessionEnsure !== undefined, + hasHistorySeed: (opts.historySeedChunks?.length ?? 0) > 0, + deadlineAt: opts.spawnAdmission?.deadlineAt ?? Number.POSITIVE_INFINITY + }) + try { + return await spawnAndPublishSession(opts, deps, { size, wslDistro }, (sessionId, isAlive) => + lease?.commit(sessionId, isAlive) + ) + } finally { + lease?.abandon() + } } async function spawnAndPublishSession( opts: InternalCreateOrAttachOptions, deps: TerminalHostSessionCreateDependencies, - ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined } + ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }, + onPublished: (sessionId: string, isAlive: boolean) => void ): Promise { const { size, wslDistro } = ctx // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. @@ -164,7 +180,8 @@ async function spawnAndPublishSession( deps.onSessionExit, opts.sessionId, opts.agentSessionGeneration, - () => discardStagedStartupCommand(staging) + () => discardStagedStartupCommand(staging), + deps.spawnGate ), ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), ...(opts.shellReadyTimeoutMs !== undefined @@ -175,17 +192,21 @@ async function spawnAndPublishSession( if (opts.isCanceled?.()) { // Retain cleanup ownership if the native child refuses to exit. deps.sessions.set(opts.sessionId, session) + // Why: a child that refuses to die stays charged against the live cap until it is reaped. + onPublished(opts.sessionId, session.isAlive) await session.forceKillAndDisposeSubprocess() if (deps.sessions.get(opts.sessionId) === session) { session.dispose() deps.sessions.delete(opts.sessionId) deps.onDeadSessionRemoved(opts.sessionId) + deps.spawnGate?.onSessionExit(opts.sessionId) } throw new TerminalAttachCanceledError(opts.sessionId) } deps.sessions.set(opts.sessionId, session) deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) + onPublished(opts.sessionId, session.isAlive) const token = session.attachClient(opts.streamClient) const startupCommandWritten = @@ -246,10 +267,12 @@ function createSessionExitHandler( onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], sessionId: string, generation: string | undefined, - discardStagedCommand: () => void + discardStagedCommand: () => void, + spawnGate: TerminalSpawnGate | undefined ): () => void { return () => { discardStagedCommand() + spawnGate?.onSessionExit(sessionId) onSessionExit(sessionId, generation) } } diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 93b4dee6e58a..2e0c3b457f26 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -43,7 +43,7 @@ export class TerminalHost { private pendingCreations = new Map>() private sessionTeardown = new TerminalSessionTeardown(this.sessions) private killedTombstones: TerminalHostTombstones - private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] + private readonly spawnOptions: Pick private onSessionReaped: TerminalHostOptions['onSessionReaped'] private reportReadinessEvent: TerminalHostOptions['reportReadinessEvent'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] @@ -59,7 +59,7 @@ export class TerminalHost { >() constructor(opts: TerminalHostOptions) { - this.spawnSubprocess = opts.spawnSubprocess + this.spawnOptions = opts this.onSessionReaped = opts.onSessionReaped this.reportReadinessEvent = opts.reportReadinessEvent this.onFinalCheckpoint = opts.onFinalCheckpoint @@ -107,7 +107,8 @@ export class TerminalHost { assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options), sessionTeardown: this.sessionTeardown, killedTombstones: this.killedTombstones, - spawnSubprocess: this.spawnSubprocess, + spawnSubprocess: this.spawnOptions.spawnSubprocess, + spawnGate: this.spawnOptions.spawnGate, onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), onSessionCreated: (sessionId, generation, isAlive) => this.agentSessionGenerations.remember(sessionId, generation, isAlive), diff --git a/src/main/daemon/terminal-spawn-gate.test.ts b/src/main/daemon/terminal-spawn-gate.test.ts new file mode 100644 index 000000000000..b6bde17049f7 --- /dev/null +++ b/src/main/daemon/terminal-spawn-gate.test.ts @@ -0,0 +1,176 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' +import { TerminalSpawnGate } from './terminal-spawn-gate' + +type SpawnSubprocess = TerminalHostOptions['spawnSubprocess'] + +function stream() { + return { onData: vi.fn(), onExit: vi.fn() } +} + +describe('TerminalSpawnGate (observe-only)', () => { + let host: TerminalHost + let gate: TerminalSpawnGate + let log: Mock<(event: string, details: Record) => void> + let exits: Map void> + let spawnSubprocess: Mock + let nextPid: number + let now: number + + beforeEach(() => { + exits = new Map() + nextPid = 1000 + now = 0 + log = vi.fn() + gate = new TerminalSpawnGate({ log, now: () => now }) + spawnSubprocess = vi.fn(() => { + const pid = nextPid++ + const handle: SubprocessHandle = { + pid, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => exits.get(pid)?.(0)), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => exits.get(pid)?.(137)), + signal: vi.fn(), + onData: vi.fn(), + onExit: (callback) => { + exits.set(pid, callback) + }, + dispose: vi.fn() + } + return handle + }) + host = new TerminalHost({ spawnSubprocess, spawnGate: gate }) + }) + + afterEach(async () => { + await host.dispose() + }) + + function restore(sessionId: string) { + return host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + historySeedChunks: ['seed'], + agentSessionEnsure: { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: sessionId.padEnd(43, 'a'), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: `tab-${sessionId}`, + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: `term_${sessionId}` + } + }, + spawnAdmission: { clientId: 'client-a', declaredIntent: null, deadlineAt: Infinity }, + streamClient: stream() + }) + } + + it('admits every automatic restore while counting the ones the policy would pace', async () => { + for (let i = 0; i < 5; i++) { + await restore(`restore-${i}`) + } + + expect(spawnSubprocess).toHaveBeenCalledTimes(5) + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 5, wouldWait: 2, wouldDefer: 0 }) + expect(log).toHaveBeenCalledWith( + 'spawn-admission-observed', + expect.objectContaining({ decision: 'wait', charge: 'automatic' }) + ) + }) + + it('never charges an attach and frees the live slot when the session exits', async () => { + await restore('restore-a') + await host.createOrAttach({ + sessionId: 'restore-a', + cols: 80, + rows: 24, + attachOnly: true, + streamClient: stream() + }) + expect(spawnSubprocess).toHaveBeenCalledOnce() + expect(gate.snapshot().liveAutomatic).toBe(1) + + exits.get(1000)?.(0) + expect(gate.snapshot().liveAutomatic).toBe(0) + }) + + it('does not count a plain shell, and releases the in-flight slot when the fork throws', async () => { + await host.createOrAttach({ sessionId: 'shell', cols: 80, rows: 24, streamClient: stream() }) + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 0, inFlight: { interactive: 0 } }) + expect(log).not.toHaveBeenCalled() + + spawnSubprocess.mockImplementationOnce(() => { + throw new Error('spawn failed') + }) + await expect(restore('restore-fails')).rejects.toThrow('spawn failed') + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 0, inFlight: { automatic: 0 } }) + }) + + it('keeps a canceled fork charged while its child refuses to die, and frees it once reaped', async () => { + let canceled = false + let release: (() => void) | undefined + spawnSubprocess.mockImplementationOnce(() => { + canceled = true + const pid = nextPid++ + const handle: SubprocessHandle = { + pid, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => { + release = () => exits.get(pid)?.(137) + }), + signal: vi.fn(), + onData: vi.fn(), + onExit: (callback) => { + exits.set(pid, callback) + }, + dispose: vi.fn() + } + return handle + }) + const pending = host.createOrAttach({ + sessionId: 'canceled-restore', + cols: 80, + rows: 24, + historySeedChunks: ['seed'], + agentSessionEnsure: { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'c'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: 'tab-canceled', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_canceled' + } + }, + isCanceled: () => canceled, + streamClient: stream() + }) + await vi.waitFor(() => expect(release).toBeDefined()) + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 1, inFlight: { automatic: 0 } }) + + release?.() + await expect(pending).rejects.toThrow(/canceled/i) + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 0, inFlight: { automatic: 0 } }) + }) +}) diff --git a/src/main/daemon/terminal-spawn-gate.ts b/src/main/daemon/terminal-spawn-gate.ts new file mode 100644 index 000000000000..448bac805f91 --- /dev/null +++ b/src/main/daemon/terminal-spawn-gate.ts @@ -0,0 +1,152 @@ +import { + applySpawnAdmission, + createSpawnAdmissionState, + decideSpawnAdmission, + DEFAULT_SPAWN_ADMISSION_LIMITS, + pruneUserResumeBuckets, + releaseLiveAutomatic, + resolveSpawnCharge, + settleSpawnAdmission, + type AutomaticWorkMode, + type HostPressureLevel, + type SpawnAdmissionDecision, + type SpawnAdmissionLimits, + type SpawnAdmissionState, + type SpawnCharge, + type SpawnRequestFacts +} from '../../shared/host-spawn-admission' + +export type TerminalSpawnLease = { + readonly charge: SpawnCharge + /** The fork published a session; only a live automatic one holds a live-cap slot. */ + commit(sessionId: string, isAlive: boolean): void + /** The fork never published a session. */ + abandon(): void +} + +export type TerminalSpawnGateSnapshot = { + mode: 'observe' + inFlight: Record + liveAutomatic: number + wouldWait: number + wouldDefer: number + wouldRefuse: number +} + +type TerminalSpawnGateOptions = { + log: (event: string, details: Record) => void + limits?: SpawnAdmissionLimits + now?: () => number + pressure?: () => HostPressureLevel + automaticWorkMode?: () => AutomaticWorkMode +} + +/** + * The daemon's single fork gate. Observe-only for now: every request is admitted + * immediately and the decision the policy would have made is counted and logged, + * so the budget defaults can be tuned from real numbers before anything waits. + */ +export class TerminalSpawnGate { + private readonly limits: SpawnAdmissionLimits + private readonly now: () => number + private readonly pressure: () => HostPressureLevel + private readonly automaticWorkMode: () => AutomaticWorkMode + private readonly state: SpawnAdmissionState + private readonly liveAutomaticSessions = new Set() + private wouldWait = 0 + private wouldDefer = 0 + private wouldRefuse = 0 + + constructor(private readonly options: TerminalSpawnGateOptions) { + this.limits = options.limits ?? DEFAULT_SPAWN_ADMISSION_LIMITS + this.now = options.now ?? Date.now + this.pressure = options.pressure ?? (() => 'unknown') + this.automaticWorkMode = options.automaticWorkMode ?? (() => ({ kind: 'normal' })) + this.state = createSpawnAdmissionState(this.limits, this.now()) + } + + admit(facts: SpawnRequestFacts): TerminalSpawnLease { + const now = this.now() + pruneUserResumeBuckets(this.state, this.limits, now) + const charge = resolveSpawnCharge(this.state, facts, this.limits, now) + const decision = decideSpawnAdmission({ + state: this.state, + facts, + charge, + pressure: this.pressure(), + mode: this.automaticWorkMode(), + limits: this.limits, + now + }) + this.recordObservation(decision, charge, facts) + applySpawnAdmission(this.state, facts, charge, this.limits, now) + let settled = false + return { + charge, + commit: (sessionId, isAlive) => { + if (settled) { + return + } + settled = true + const becameLive = charge === 'automatic' && isAlive + settleSpawnAdmission(this.state, charge, becameLive) + if (becameLive) { + this.liveAutomaticSessions.add(sessionId) + } + }, + abandon: () => { + if (settled) { + return + } + settled = true + settleSpawnAdmission(this.state, charge, false) + } + } + } + + onSessionExit(sessionId: string): void { + if (this.liveAutomaticSessions.delete(sessionId)) { + releaseLiveAutomatic(this.state) + } + } + + snapshot(): TerminalSpawnGateSnapshot { + return { + mode: 'observe', + inFlight: { ...this.state.inFlight }, + liveAutomatic: this.state.liveAutomatic, + wouldWait: this.wouldWait, + wouldDefer: this.wouldDefer, + wouldRefuse: this.wouldRefuse + } + } + + private recordObservation( + decision: SpawnAdmissionDecision, + charge: SpawnCharge, + facts: SpawnRequestFacts + ): void { + if (decision.kind === 'admit' && charge === 'interactive') { + return + } + if (decision.kind === 'wait') { + this.wouldWait += 1 + } else if (decision.kind === 'defer') { + this.wouldDefer += 1 + } else if (decision.kind === 'refuse_retryable') { + this.wouldRefuse += 1 + } + try { + this.options.log('spawn-admission-observed', { + decision: decision.kind, + ...(decision.kind === 'defer' ? { reason: decision.reason } : {}), + charge, + declaredIntent: facts.declaredIntent, + inFlightAutomatic: this.state.inFlight.automatic, + liveAutomatic: this.state.liveAutomatic + }) + } catch { + // Diagnostics must never turn an admitted fork into a failed create. + } + } +} diff --git a/src/shared/host-spawn-admission.test.ts b/src/shared/host-spawn-admission.test.ts new file mode 100644 index 000000000000..940e72e75286 --- /dev/null +++ b/src/shared/host-spawn-admission.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { + applySpawnAdmission, + createSpawnAdmissionState, + decideSpawnAdmission, + DEFAULT_SPAWN_ADMISSION_LIMITS as LIMITS, + releaseLiveAutomatic, + resolveSpawnCharge, + settleSpawnAdmission, + type AutomaticWorkMode, + type HostPressureLevel, + type SpawnRequestFacts +} from './host-spawn-admission' + +const NORMAL: AutomaticWorkMode = { kind: 'normal' } + +function facts(overrides: Partial = {}): SpawnRequestFacts { + return { + clientId: 'client-a', + declaredIntent: null, + hasAgentSessionClaim: true, + hasHistorySeed: true, + deadlineAt: Number.POSITIVE_INFINITY, + ...overrides + } +} + +function decide( + state: ReturnType, + f: SpawnRequestFacts, + now: number, + pressure: HostPressureLevel = 'ok', + mode: AutomaticWorkMode = NORMAL +) { + const charge = resolveSpawnCharge(state, f, LIMITS, now) + return decideSpawnAdmission({ state, facts: f, charge, pressure, mode, limits: LIMITS, now }) +} + +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +describe('resolveSpawnCharge', () => { + it('infers automatic restore for an old caller only from a claim plus a history seed', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + expect(resolveSpawnCharge(state, facts(), LIMITS, 0)).toBe('automatic') + expect(resolveSpawnCharge(state, facts({ hasHistorySeed: false }), LIMITS, 0)).toBe( + 'interactive' + ) + expect(resolveSpawnCharge(state, facts({ hasAgentSessionClaim: false }), LIMITS, 0)).toBe( + 'interactive' + ) + }) + + it('charges a declared user resume as automatic once its per-client bucket is empty', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + const f = facts({ declaredIntent: 'user-resume' }) + for (let i = 0; i < LIMITS.userResumeBucketCapacity; i++) { + expect(resolveSpawnCharge(state, f, LIMITS, 0)).toBe('user') + applySpawnAdmission(state, f, 'user', LIMITS, 0) + settleSpawnAdmission(state, 'user', true) + } + expect(resolveSpawnCharge(state, f, LIMITS, 0)).toBe('automatic') + expect( + resolveSpawnCharge(state, facts({ declaredIntent: 'user-resume', clientId: 'b' }), LIMITS, 0) + ).toBe('user') + expect(resolveSpawnCharge(state, f, LIMITS, LIMITS.userResumeBucketWindowMs)).toBe('user') + }) +}) + +describe('decideSpawnAdmission', () => { + it('always admits interactive forks, even under critical pressure and pause', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + const f = facts({ declaredIntent: 'interactive' }) + expect( + decide(state, f, 0, 'critical', { kind: 'paused', reason: 'operator', since: 0 }) + ).toEqual({ kind: 'admit', charge: 'interactive' }) + }) + + it('paces automatic restores to the burst, then one per second', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + const f = facts() + for (let i = 0; i < LIMITS.automaticBurst; i++) { + expect(decide(state, f, 0).kind).toBe('admit') + applySpawnAdmission(state, f, 'automatic', LIMITS, 0) + settleSpawnAdmission(state, 'automatic', true) + } + expect(decide(state, f, 0)).toEqual({ kind: 'wait', charge: 'automatic', retryAt: 1000 }) + expect(decide(state, f, 1000).kind).toBe('admit') + }) + + it('waits for a released slot when automatic concurrency is full', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + const f = facts() + for (let i = 0; i < LIMITS.automaticConcurrency; i++) { + applySpawnAdmission(state, f, 'automatic', LIMITS, 0) + } + expect(decide(state, f, 60_000)).toEqual({ kind: 'wait', charge: 'automatic', retryAt: null }) + }) + + it('defers negotiated requests for pause, live cap and pressure, but never old callers', () => { + const paused: AutomaticWorkMode = { kind: 'paused', reason: 'unclean_host_death', since: 0 } + const negotiated = facts({ declaredIntent: 'automatic-restore' }) + const state = createSpawnAdmissionState(LIMITS, 0) + expect(decide(state, negotiated, 0, 'ok', paused)).toEqual({ kind: 'defer', reason: 'paused' }) + expect(decide(state, facts(), 0, 'ok', paused).kind).toBe('admit') + expect(decide(state, negotiated, 0, 'critical')).toEqual({ kind: 'defer', reason: 'pressure' }) + expect(decide(state, facts(), 0, 'critical')).toEqual({ kind: 'refuse_retryable' }) + + state.liveAutomatic = LIMITS.maxLiveAutomatic + expect(decide(state, negotiated, 0)).toEqual({ kind: 'defer', reason: 'live_cap' }) + expect(decide(state, facts(), 0).kind).toBe('admit') + }) + + it('admits an old caller at its deadline instead of refusing, and defers a negotiated one', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + state.automaticBucket = { tokens: 0, updatedAt: 0 } + expect(decide(state, facts({ deadlineAt: 500 }), 0)).toEqual({ + kind: 'admit', + charge: 'automatic' + }) + expect(decide(state, facts({ deadlineAt: 500, declaredIntent: 'automation' }), 0)).toEqual({ + kind: 'defer', + reason: 'deadline' + }) + }) + + it('admits a user resume past its deadline instead of waiting forever for a slot', () => { + const state = createSpawnAdmissionState(LIMITS, 0) + const f = facts({ declaredIntent: 'user-resume', deadlineAt: 500 }) + for (let i = 0; i < LIMITS.userResumeConcurrency; i++) { + applySpawnAdmission(state, f, 'user', LIMITS, 0) + } + expect(decide(state, f, 0)).toEqual({ kind: 'wait', charge: 'user', retryAt: null }) + expect(decide(state, f, 500)).toEqual({ kind: 'admit', charge: 'user' }) + }) + + it('M-1: random arrivals keep the pacing and live-cap invariants', () => { + for (let seed = 1; seed <= 25; seed++) { + const rng = mulberry32(seed) + const state = createSpawnAdmissionState(LIMITS, 0) + const live: { charge: 'automatic' | 'user' | 'interactive'; endsAt: number }[] = [] + const inFlight: { charge: 'automatic' | 'user' | 'interactive'; settlesAt: number }[] = [] + let now = 0 + let automaticAdmitsInWindow: number[] = [] + for (let step = 0; step < 400; step++) { + now += Math.floor(rng() * 400) + for (const item of inFlight.splice(0).filter((entry) => { + if (entry.settlesAt > now) { + inFlight.push(entry) + return false + } + return true + })) { + settleSpawnAdmission(state, item.charge, true) + live.push({ charge: item.charge, endsAt: now + Math.floor(rng() * 30_000) }) + } + for (const entry of live.splice(0)) { + if (entry.endsAt <= now) { + if (entry.charge === 'automatic') { + releaseLiveAutomatic(state) + } + } else { + live.push(entry) + } + } + const roll = rng() + const f = facts({ + declaredIntent: + roll < 0.2 + ? 'interactive' + : roll < 0.3 + ? 'user-resume' + : roll < 0.7 + ? 'automatic-restore' + : null, + clientId: rng() < 0.5 ? 'a' : 'b' + }) + const pressure: HostPressureLevel = rng() < 0.05 ? 'critical' : 'ok' + const decision = decide(state, f, now, pressure) + if (f.declaredIntent === 'interactive') { + expect(decision).toEqual({ kind: 'admit', charge: 'interactive' }) + } + if (f.declaredIntent === null && pressure !== 'critical') { + expect(['admit', 'wait']).toContain(decision.kind) + } + if (decision.kind !== 'admit') { + continue + } + applySpawnAdmission(state, f, decision.charge, LIMITS, now) + inFlight.push({ charge: decision.charge, settlesAt: now + Math.floor(rng() * 2000) }) + if (decision.charge === 'automatic') { + automaticAdmitsInWindow = [ + ...automaticAdmitsInWindow.filter((t) => t > now - 10_000), + now + ] + expect(state.inFlight.automatic).toBeLessThanOrEqual(LIMITS.automaticConcurrency) + // Burst plus one token per second over a 10 s window. + expect(automaticAdmitsInWindow.length).toBeLessThanOrEqual(LIMITS.automaticBurst + 10) + if (f.declaredIntent !== null) { + expect(state.liveAutomatic + state.inFlight.automatic).toBeLessThanOrEqual( + LIMITS.maxLiveAutomatic + ) + } + } + } + } + }) +}) diff --git a/src/shared/host-spawn-admission.ts b/src/shared/host-spawn-admission.ts new file mode 100644 index 000000000000..2b865d160c45 --- /dev/null +++ b/src/shared/host-spawn-admission.ts @@ -0,0 +1,241 @@ +// Pure, clock-injected admission policy for PTY forks on an execution host. +// The creation owner (the terminal daemon) calls it only on the branch that is +// about to fork, so attach and adoption are never charged. + +export type SpawnIntent = 'interactive' | 'user-resume' | 'automatic-restore' | 'automation' + +export type SpawnCharge = 'interactive' | 'user' | 'automatic' + +export type HostPressureLevel = 'ok' | 'elevated' | 'critical' | 'unknown' + +export type AutomaticWorkMode = + | { kind: 'normal' } + | { + kind: 'paused' + reason: 'unclean_host_death' | 'pressure' | 'operator' + since: number + } + +export type SpawnRequestFacts = { + clientId: string + /** Null when the caller predates the field; presence opts the request into `defer`. */ + declaredIntent: SpawnIntent | null + hasAgentSessionClaim: boolean + hasHistorySeed: boolean + /** Latest time a paced request may still be admitted, on the gate's clock. */ + deadlineAt: number +} + +export type SpawnAdmissionDecision = + | { kind: 'admit'; charge: SpawnCharge } + /** `retryAt` null means "until a slot is released". */ + | { kind: 'wait'; charge: SpawnCharge; retryAt: number | null } + | { kind: 'defer'; reason: 'paused' | 'live_cap' | 'pressure' | 'deadline' } + | { kind: 'refuse_retryable' } + +export type SpawnAdmissionLimits = { + automaticConcurrency: number + automaticTokensPerSecond: number + automaticBurst: number + maxLiveAutomatic: number + userResumeConcurrency: number + userResumeBucketCapacity: number + userResumeBucketWindowMs: number +} + +// Initial defaults; tune from the observe-only counts. +export const DEFAULT_SPAWN_ADMISSION_LIMITS: SpawnAdmissionLimits = { + automaticConcurrency: 3, + automaticTokensPerSecond: 1, + automaticBurst: 3, + maxLiveAutomatic: 8, + userResumeConcurrency: 3, + userResumeBucketCapacity: 4, + userResumeBucketWindowMs: 10_000 +} + +type TokenBucket = { tokens: number; updatedAt: number } + +export type SpawnAdmissionState = { + inFlight: Record + liveAutomatic: number + automaticBucket: TokenBucket + userResumeBuckets: Map +} + +export function createSpawnAdmissionState( + limits: SpawnAdmissionLimits, + now: number +): SpawnAdmissionState { + return { + inFlight: { interactive: 0, user: 0, automatic: 0 }, + liveAutomatic: 0, + automaticBucket: { tokens: limits.automaticBurst, updatedAt: now }, + userResumeBuckets: new Map() + } +} + +function refill( + bucket: TokenBucket, + capacity: number, + tokensPerMs: number, + now: number +): TokenBucket { + const elapsed = Math.max(0, now - bucket.updatedAt) + return { tokens: Math.min(capacity, bucket.tokens + elapsed * tokensPerMs), updatedAt: now } +} + +function automaticTokensPerMs(limits: SpawnAdmissionLimits): number { + return limits.automaticTokensPerSecond / 1000 +} + +function userResumeTokensPerMs(limits: SpawnAdmissionLimits): number { + return limits.userResumeBucketCapacity / limits.userResumeBucketWindowMs +} + +/** Old callers get inference from facts the host holds; a declared `user-resume` + * above its per-client bucket is charged as automatic so it cannot bypass pacing. */ +export function resolveSpawnCharge( + state: SpawnAdmissionState, + facts: SpawnRequestFacts, + limits: SpawnAdmissionLimits, + now: number +): SpawnCharge { + const intent = + facts.declaredIntent ?? + (facts.hasAgentSessionClaim && facts.hasHistorySeed ? 'automatic-restore' : 'interactive') + if (intent === 'interactive') { + return 'interactive' + } + if (intent === 'user-resume') { + const bucket = refill( + state.userResumeBuckets.get(facts.clientId) ?? { + tokens: limits.userResumeBucketCapacity, + updatedAt: now + }, + limits.userResumeBucketCapacity, + userResumeTokensPerMs(limits), + now + ) + return bucket.tokens >= 1 ? 'user' : 'automatic' + } + return 'automatic' +} + +export function decideSpawnAdmission(args: { + state: SpawnAdmissionState + facts: SpawnRequestFacts + charge: SpawnCharge + pressure: HostPressureLevel + mode: AutomaticWorkMode + limits: SpawnAdmissionLimits + now: number +}): SpawnAdmissionDecision { + const { state, facts, charge, pressure, mode, limits, now } = args + if (charge === 'interactive') { + return { kind: 'admit', charge } + } + if (charge === 'user') { + // Why: a user action is never refused; past its deadline it goes ahead regardless. + return state.inFlight.user < limits.userResumeConcurrency || now >= facts.deadlineAt + ? { kind: 'admit', charge } + : { kind: 'wait', charge, retryAt: null } + } + const negotiated = facts.declaredIntent !== null + if (pressure === 'critical') { + // Why: an old caller clears its resume handle before spawning, so only critical + // pressure may refuse it, with the error it already treats as "reconnect". + return negotiated ? { kind: 'defer', reason: 'pressure' } : { kind: 'refuse_retryable' } + } + if (negotiated && mode.kind === 'paused') { + return { kind: 'defer', reason: 'paused' } + } + if (negotiated && state.liveAutomatic + state.inFlight.automatic >= limits.maxLiveAutomatic) { + return { kind: 'defer', reason: 'live_cap' } + } + const bucket = refill( + state.automaticBucket, + limits.automaticBurst, + automaticTokensPerMs(limits), + now + ) + const concurrencyFull = state.inFlight.automatic >= limits.automaticConcurrency + if (!concurrencyFull && bucket.tokens >= 1) { + return { kind: 'admit', charge } + } + const retryAt = concurrencyFull + ? null + : now + Math.ceil((1 - bucket.tokens) / automaticTokensPerMs(limits)) + if (retryAt !== null ? retryAt > facts.deadlineAt : now >= facts.deadlineAt) { + // Why: an old caller is admitted at its deadline rather than refused (degraded pacing). + return negotiated ? { kind: 'defer', reason: 'deadline' } : { kind: 'admit', charge } + } + return { kind: 'wait', charge, retryAt } +} + +/** Applies an admit's accounting. Buckets may not go below zero, so an observe-only + * gate that admits everything still reports realistic would-wait counts. */ +export function applySpawnAdmission( + state: SpawnAdmissionState, + facts: SpawnRequestFacts, + charge: SpawnCharge, + limits: SpawnAdmissionLimits, + now: number +): void { + state.inFlight[charge] += 1 + if (charge === 'automatic') { + const bucket = refill( + state.automaticBucket, + limits.automaticBurst, + automaticTokensPerMs(limits), + now + ) + state.automaticBucket = { tokens: Math.max(0, bucket.tokens - 1), updatedAt: now } + } else if (charge === 'user') { + const bucket = refill( + state.userResumeBuckets.get(facts.clientId) ?? { + tokens: limits.userResumeBucketCapacity, + updatedAt: now + }, + limits.userResumeBucketCapacity, + userResumeTokensPerMs(limits), + now + ) + state.userResumeBuckets.set(facts.clientId, { + tokens: Math.max(0, bucket.tokens - 1), + updatedAt: now + }) + } +} + +/** Ends an admitted fork's in-flight slot; `becameLive` counts it against the live cap. */ +export function settleSpawnAdmission( + state: SpawnAdmissionState, + charge: SpawnCharge, + becameLive: boolean +): void { + state.inFlight[charge] = Math.max(0, state.inFlight[charge] - 1) + if (charge === 'automatic' && becameLive) { + state.liveAutomatic += 1 + } +} + +export function releaseLiveAutomatic(state: SpawnAdmissionState): void { + state.liveAutomatic = Math.max(0, state.liveAutomatic - 1) +} + +/** Drops idle full buckets so a long-lived host does not keep one per past client. */ +export function pruneUserResumeBuckets( + state: SpawnAdmissionState, + limits: SpawnAdmissionLimits, + now: number +): void { + for (const [clientId, bucket] of state.userResumeBuckets) { + if ( + refill(bucket, limits.userResumeBucketCapacity, userResumeTokensPerMs(limits), now).tokens >= + limits.userResumeBucketCapacity + ) { + state.userResumeBuckets.delete(clientId) + } + } +} From 2148111db01246aba12eb00d11c1d5ed2c6f816d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:37:22 -0700 Subject: [PATCH 41/58] refactor(renderer): delete dead owner and settings shims (#27430) Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 5 +- .../use-work-item-source-foundation.ts | 12 +---- .../RepositoryGitHubAccountSection.test.tsx | 4 -- ...e-worktree-dirty-change-hydration.test.tsx | 3 -- .../src/lib/repo-runtime-owner.test.ts | 52 +------------------ src/renderer/src/lib/repo-runtime-owner.ts | 21 -------- src/renderer/src/lib/resolve-owner.test.ts | 22 ++------ src/renderer/src/lib/resolve-owner.ts | 39 +------------- .../src/lib/startup-draft-input-kind.test.ts | 1 - .../lib/unstamped-non-local-row-counter.ts | 18 ------- .../src/lib/worktree-operation-route.ts | 14 ----- .../src/lib/worktree-runtime-owner.test.ts | 41 ++++----------- .../src/lib/worktree-runtime-owner.ts | 11 ---- .../worktree-host-ownership-unstamped.test.ts | 27 ++++------ .../listing/worktree-host-ownership.ts | 12 +---- .../listing/worktree-owner-settings.ts | 33 ------------ 16 files changed, 33 insertions(+), 282 deletions(-) delete mode 100644 src/renderer/src/lib/unstamped-non-local-row-counter.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index bf1bfa49004c..2054b133abef 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -346,8 +346,7 @@ 2 src/renderer/src/lib/provider-runtime-context.ts 2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts -4 src/renderer/src/lib/repo-runtime-owner.ts -2 src/renderer/src/lib/resolve-owner.ts +3 src/renderer/src/lib/repo-runtime-owner.ts 2 src/renderer/src/lib/resume-sleeping-agent-session.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts @@ -377,7 +376,7 @@ 1 src/renderer/src/lib/worktree-jump-navigation.ts 2 src/renderer/src/lib/worktree-operation-route.ts 1 src/renderer/src/lib/worktree-row-open-in-owner.ts -6 src/renderer/src/lib/worktree-runtime-owner.ts +5 src/renderer/src/lib/worktree-runtime-owner.ts 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts diff --git a/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts b/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts index 01198f759015..44856ede9afb 100644 --- a/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts +++ b/src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts @@ -2,7 +2,6 @@ import React, { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' -import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-context' import type { NormalizedWorkItemSourceSearchProps, @@ -41,8 +40,7 @@ export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearc expectedPreflightContextKey, refreshPreflightStatus, searchJiraIssues, - searchLinearIssues, - settings + searchLinearIssues } = useAppStore( useShallow((s) => ({ checkLinearConnection: s.checkLinearConnection, @@ -59,18 +57,13 @@ export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearc expectedPreflightContextKey: localPreflightContextKey(getLocalPreflightContext(s)), refreshPreflightStatus: s.refreshPreflightStatus, searchJiraIssues: s.searchJiraIssues, - searchLinearIssues: s.searchLinearIssues, - settings: s.settings + searchLinearIssues: s.searchLinearIssues })) ) const selectedRepo = useMemo( () => repos.find((repo) => repo.id === repoId) ?? null, [repoId, repos] ) - const selectedRepoOwnerSettings = useMemo( - () => getRepoOwnerRoutedSettings(settings, selectedRepo), - [selectedRepo, settings] - ) const githubSourceContext = useMemo(() => { if (githubSourceContextOverride?.provider === 'github') { return githubSourceContextOverride @@ -202,7 +195,6 @@ export function useWorkItemSourceFoundation(props: NormalizedWorkItemSourceSearc searchJiraIssues, searchLinearIssues, selectedRepo, - selectedRepoOwnerSettings, githubSourceContext, gitlabSourceContext, repoBackedSearchTargets, diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx index bcbb90d6faad..b5b911ce6218 100644 --- a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx @@ -33,10 +33,6 @@ vi.mock('../../store', () => { return { useAppStore } }) -vi.mock('@/lib/repo-runtime-owner', () => ({ - getRepoOwnerRoutedSettings: (settings: unknown) => settings -})) - vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) diff --git a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx index 14755d8ead7e..21de6daec9a8 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx @@ -33,9 +33,6 @@ vi.mock('@/store', () => ({ })) vi.mock('@/runtime/runtime-git-client', () => ({ getRuntimeGitStatus: vi.fn() })) vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getSettingsForWorktreeRuntimeOwner: () => null -})) const repoMap = new Map() diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index 6f7e61282ac2..d01025c88424 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -1,13 +1,11 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RepoRuntimeOwnerState } from './repo-runtime-owner' import { getExplicitRuntimeOwnerEnvironmentId, - getRepoOwnerRoutedSettings, getRuntimeEnvironmentIdForRepo, getSettingsForRepoRuntimeOwner, - runtimeTargetForRepoOwner, - releaseRepoRuntimeOwnerSettingsCache + runtimeTargetForRepoOwner } from './repo-runtime-owner' describe('getRuntimeEnvironmentIdForRepo', () => { @@ -196,53 +194,7 @@ describe('getExplicitRuntimeOwnerEnvironmentId', () => { }) }) -describe('getRepoOwnerRoutedSettings', () => { - // Why: SourceControl builds its git/file mutation contexts from this value, - // so it must rebind activeRuntimeEnvironmentId to the repo OWNER even while a - // different host is focused — otherwise stage/commit/push hit the wrong host. - it('routes a git mutation context for a runtime-owned active repo to the owner, not the focused runtime', () => { - const settings = { - activeRuntimeEnvironmentId: 'focused-runtime', - sourceControlViewMode: 'list' - } as unknown as GlobalSettings - - const routed = getRepoOwnerRoutedSettings(settings, { - id: 'repo-1', - connectionId: null, - executionHostId: 'runtime:owner-runtime' - }) - - expect(routed?.activeRuntimeEnvironmentId).toBe('owner-runtime') - // Non-routing (display) fields must survive the rebind untouched. - expect((routed as { sourceControlViewMode?: string }).sourceControlViewMode).toBe('list') - }) - - it('falls back to the focused runtime for a legacy repo without an explicit owner', () => { - const settings = { activeRuntimeEnvironmentId: 'focused-runtime' } as unknown as GlobalSettings - const routed = getRepoOwnerRoutedSettings(settings, { - id: 'repo-1', - connectionId: null, - executionHostId: null - }) - expect(routed?.activeRuntimeEnvironmentId).toBe('focused-runtime') - }) - - it('passes null settings through unchanged', () => { - expect( - getRepoOwnerRoutedSettings(null, { - id: 'repo-1', - connectionId: null, - executionHostId: null - }) - ).toBeNull() - }) -}) - describe('getSettingsForRepoRuntimeOwner identity', () => { - beforeEach(() => { - releaseRepoRuntimeOwnerSettingsCache() - }) - type OwnerRepo = NonNullable[number] const repos: OwnerRepo[] = [ { id: 'repo-1', connectionId: null, executionHostId: 'runtime:env-a' } diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index d947a686cfcb..b2cb3a8ee6a3 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -109,10 +109,6 @@ const ownerRoutedSettingsCache = new Map( // Why bounded: repo ids come from remote listings, so the key space is not closed. const MAX_OWNER_ROUTED_SETTINGS_CACHE_KEYS = 256 -export function releaseRepoRuntimeOwnerSettingsCache(): void { - ownerRoutedSettingsCache.clear() -} - export function getSettingsForRepoRuntimeOwner( state: RepoRuntimeOwnerState, repoId: string | null | undefined @@ -150,20 +146,3 @@ export function getSettingsForRepoRuntimeOwner( } return value } - -// Why: git/file/terminal mutations must route by the OWNER host of the repo, -// not the currently focused runtime. This rebinds activeRuntimeEnvironmentId to -// the repo owner while preserving every other (display/AI) settings field. -export function getRepoOwnerRoutedSettings( - settings: T, - repo: Pick | null | undefined -): T { - if (!settings) { - return settings - } - const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForRepo( - { repos: repo ? [repo] : [], settings }, - repo?.id ?? null - ) - return { ...settings, activeRuntimeEnvironmentId } -} diff --git a/src/renderer/src/lib/resolve-owner.test.ts b/src/renderer/src/lib/resolve-owner.test.ts index 2c20288b1b42..822f5c8b5a72 100644 --- a/src/renderer/src/lib/resolve-owner.test.ts +++ b/src/renderer/src/lib/resolve-owner.test.ts @@ -1,12 +1,9 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { HostAuthority } from '../../../shared/host-authority' import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution' import { - getLegacyFocusFallbackCount, - resetLegacyFocusFallbackCountForTest, resolveOwner, - resolveOwnerWithLegacyFocus, runtimeTargetForWorkspaceOwner, type WorkspaceOwnerRef } from './resolve-owner' @@ -60,8 +57,6 @@ type Row = { rows: WorktreeOperationRouteState ref: WorkspaceOwnerRef owner: Match - /** Today's focus-reading answer where it differs from `owner`. */ - legacy?: Partial> savedServers?: string[] } @@ -76,16 +71,14 @@ const ROWS: Row[] = [ name: 'local, unstamped legacy row', rows: { repos: [{ id: 'repo-1' }], worktreesByRepo: { 'repo-1': [worktree()] } }, ref: { workspaceId: WORKTREE_ID }, - owner: selfAt('local'), - legacy: { other: missing, deleted: missing } + owner: selfAt('local') }, { name: 'local, unstamped legacy row, one saved server', rows: { repos: [{ id: 'repo-1' }], worktreesByRepo: { 'repo-1': [worktree()] } }, ref: { workspaceId: WORKTREE_ID }, owner: selfAt('local'), - savedServers: ['env-other'], - legacy: { other: envAt('env-other'), deleted: missing } + savedServers: ['env-other'] }, { name: 'direct SSH', @@ -208,8 +201,7 @@ const ROWS: Row[] = [ rows: { folderWorkspaces: [folder(null)] }, ref: { workspaceId: 'folder:f1' }, owner: selfAt('local'), - savedServers: ['env-other'], - legacy: { other: envAt('env-other') } + savedServers: ['env-other'] }, { name: 'unknown id', @@ -229,16 +221,10 @@ function withActiveServer(row: Row, active: ActiveServer): WorktreeOperationRout } describe('resolveOwner characterization', () => { - beforeEach(() => resetLegacyFocusFallbackCountForTest()) - describe.each(ROWS)('$name', (row) => { it.each(ACTIVE_SERVERS)('Active Server = %s', (active) => { const state = withActiveServer(row, active) expect(resolveOwner(state, row.ref)).toEqual(row.owner) - - const legacy = row.legacy?.[active] - expect(resolveOwnerWithLegacyFocus(state, row.ref)).toEqual(legacy ?? row.owner) - expect(getLegacyFocusFallbackCount()).toBe(legacy ? 1 : 0) }) }) }) diff --git a/src/renderer/src/lib/resolve-owner.ts b/src/renderer/src/lib/resolve-owner.ts index c2753f50980b..e58b4ab046b7 100644 --- a/src/renderer/src/lib/resolve-owner.ts +++ b/src/renderer/src/lib/resolve-owner.ts @@ -1,7 +1,6 @@ /** * The owner of a workspace as a {@link HostAuthority}, decided from the rows alone. It never reads - * the "Active Server" focus setting; the one transitional path that does is - * {@link resolveOwnerWithLegacyFocus}, which counts every answer focus changed. + * the "Active Server" focus setting. */ import { getRepoSshConnectionId, @@ -114,39 +113,3 @@ export function runtimeTargetForWorkspaceOwner( const match = resolveOwner(state, ref) return match.kind === 'resolved' ? hostRouteForAuthority(match.owner).target : null } - -let legacyFocusFallbackCount = 0 - -/** How many answers focus has changed since start; a slice drops its fallback once this stays 0. */ -export function getLegacyFocusFallbackCount(): number { - return legacyFocusFallbackCount -} - -export function resetLegacyFocusFallbackCountForTest(): void { - legacyFocusFallbackCount = 0 -} - -function sameOwnerMatch( - a: ExecutionHostOwnerMatch, - b: ExecutionHostOwnerMatch -): boolean { - return JSON.stringify(a) === JSON.stringify(b) -} - -/** - * {@link resolveOwner} plus today's focus fallback, which lets a single focused server claim rows - * that predate owner stamping. Transitional: callers move to {@link resolveOwner} slice by slice. - */ -export function resolveOwnerWithLegacyFocus( - state: WorktreeOperationRouteState, - ref: WorkspaceOwnerRef -): ExecutionHostOwnerMatch { - const legacy = resolveRoute(state, ref) - if ( - state.settings?.activeRuntimeEnvironmentId?.trim() && - !sameOwnerMatch(legacy, resolveOwner(state, ref)) - ) { - legacyFocusFallbackCount += 1 - } - return legacy -} diff --git a/src/renderer/src/lib/startup-draft-input-kind.test.ts b/src/renderer/src/lib/startup-draft-input-kind.test.ts index 935a92909b78..16a38cb9a791 100644 --- a/src/renderer/src/lib/startup-draft-input-kind.test.ts +++ b/src/renderer/src/lib/startup-draft-input-kind.test.ts @@ -23,7 +23,6 @@ vi.mock('@/store', () => ({ })) vi.mock('./worktree-runtime-owner', () => ({ - getSettingsForWorktreeRuntimeOwner: () => ({}), getRuntimeEnvironmentIdForWorktree: () => null })) diff --git a/src/renderer/src/lib/unstamped-non-local-row-counter.ts b/src/renderer/src/lib/unstamped-non-local-row-counter.ts deleted file mode 100644 index 2fca51b2559f..000000000000 --- a/src/renderer/src/lib/unstamped-non-local-row-counter.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Dev counter for rows that reached the store without an owner stamp yet were attributed to a - * non-local host by inference. Every ingest path stamps through `adoptFromEndpoint`, so this - * should stay 0; a reader that has to guess a non-local owner reports it here. - */ -let unstampedNonLocalRowCount = 0 - -export function noteUnstampedNonLocalRow(): void { - unstampedNonLocalRowCount += 1 -} - -export function getUnstampedNonLocalRowCount(): number { - return unstampedNonLocalRowCount -} - -export function resetUnstampedNonLocalRowCountForTest(): void { - unstampedNonLocalRowCount = 0 -} diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index 6d89b75adca1..be50836e38c0 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -374,17 +374,3 @@ function resolveFolderWorkspaceOperationRoute( } } } - -/** - * Projects the route's runtime environment onto settings so a routed operation runs against the - * owner's environment rather than whichever one the UI has active; settings can still be absent - * during early hydration, hence the synthesized fallback. - */ -export function settingsForWorktreeOperationRoute( - settings: AppState['settings'], - route: WorktreeOperationRoute -): AppState['settings'] { - return settings - ? { ...settings, activeRuntimeEnvironmentId: route.runtimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: route.runtimeEnvironmentId } as AppState['settings']) -} diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index caea3fb4ad48..afa8a280115b 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -6,7 +6,6 @@ import { getKnownExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree, getRuntimeSessionMirrorEnvironmentIds, - getSettingsForWorktreeRuntimeOwner, type WorktreeRuntimeOwnerState } from './worktree-runtime-owner' @@ -36,17 +35,13 @@ const state: WorktreeRuntimeOwnerState = { ] } -describe('getSettingsForWorktreeRuntimeOwner', () => { +describe('getRuntimeEnvironmentIdForWorktree', () => { it('routes to the runtime owner of the worktree', () => { - expect(getSettingsForWorktreeRuntimeOwner(state, 'runtime-repo::wt-b')).toEqual({ - activeRuntimeEnvironmentId: 'owner-env' - }) + expect(getRuntimeEnvironmentIdForWorktree(state, 'runtime-repo::wt-b')).toBe('owner-env') }) it('keeps explicit-local worktrees local even while a runtime is focused', () => { - expect(getSettingsForWorktreeRuntimeOwner(state, 'local-repo::wt-a')).toEqual({ - activeRuntimeEnvironmentId: null - }) + expect(getRuntimeEnvironmentIdForWorktree(state, 'local-repo::wt-a')).toBeNull() }) it('keeps the synthetic floating workspace local while a runtime is focused', () => { @@ -55,11 +50,6 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, activeWorkspaceExecutionHostId: 'runtime:focused-env' } - expect( - getSettingsForWorktreeRuntimeOwner(floatingState, FLOATING_TERMINAL_WORKTREE_ID) - ).toEqual({ - activeRuntimeEnvironmentId: null - }) expect( getRuntimeEnvironmentIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID) ).toBeNull() @@ -72,9 +62,7 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { }) it('routes folder workspaces to their project group runtime owner', () => { - expect(getSettingsForWorktreeRuntimeOwner(state, 'folder:runtime-folder')).toEqual({ - activeRuntimeEnvironmentId: 'folder-env' - }) + expect(getRuntimeEnvironmentIdForWorktree(state, 'folder:runtime-folder')).toBe('folder-env') expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env') }) @@ -109,11 +97,6 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { } } - expect( - getSettingsForWorktreeRuntimeOwner(restoredFolderState, 'folder:restored-folder') - ).toEqual({ - activeRuntimeEnvironmentId: 'restored-env' - }) expect(getRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe( 'restored-env' ) @@ -126,9 +109,7 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { }) it('keeps explicit-local folder workspaces local even while a runtime is focused', () => { - expect(getSettingsForWorktreeRuntimeOwner(state, 'folder:local-folder')).toEqual({ - activeRuntimeEnvironmentId: null - }) + expect(getRuntimeEnvironmentIdForWorktree(state, 'folder:local-folder')).toBeNull() expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local') const restoredOwnerState: WorktreeRuntimeOwnerState = { @@ -150,9 +131,9 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { ] } - expect(getSettingsForWorktreeRuntimeOwner(folderConnectionState, 'folder:folder-ssh')).toEqual({ - activeRuntimeEnvironmentId: null - }) + expect( + getRuntimeEnvironmentIdForWorktree(folderConnectionState, 'folder:folder-ssh') + ).toBeNull() expect(getExecutionHostIdForWorktree(folderConnectionState, 'folder:folder-ssh')).toBe( 'ssh:folder-remote' ) @@ -167,10 +148,8 @@ describe('getSettingsForWorktreeRuntimeOwner', () => { } expect( - getSettingsForWorktreeRuntimeOwner(staleFolderConnectionState, 'folder:runtime-folder') - ).toEqual({ - activeRuntimeEnvironmentId: 'folder-env' - }) + getRuntimeEnvironmentIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder') + ).toBe('folder-env') expect(getExecutionHostIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder')).toBe( 'runtime:folder-env' ) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 3c04be33ab1b..9b0df03cc372 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -5,7 +5,6 @@ import { UNRESOLVED_OWNER_HOST_ID } from '../../../shared/execution-host' import type { ExecutionHostId } from '../../../shared/execution-host' -import type { GlobalSettings } from '../../../shared/global-settings-types' import type { Worktree } from '../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' @@ -249,13 +248,3 @@ export function getExecutionHostIdForWorktree( getFocusedRuntimeOrLocalExecutionHostId(state) ) } - -export function getSettingsForWorktreeRuntimeOwner( - state: WorktreeRuntimeOwnerState, - worktreeId: string | null | undefined -): Pick { - return { - ...state.settings, - activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts index 66f5eddecc86..777c6788aed1 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership-unstamped.test.ts @@ -1,24 +1,17 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { - getUnstampedNonLocalRowCount, - resetUnstampedNonLocalRowCountForTest -} from '@/lib/unstamped-non-local-row-counter' +import { describe, expect, it } from 'vitest' import { worktreeMatchesHost } from './worktree-host-ownership' describe('worktreeMatchesHost unstamped non-local rows', () => { - beforeEach(() => resetUnstampedNonLocalRowCountForTest()) - it.each([ - ['stamped SSH row', { hostId: 'ssh:t' as const }, 'ssh:t' as const, true, true, 0], - ['stamped server row', { runtimeOwnerEnvironmentId: 'e' }, 'runtime:e' as const, true, true, 0], - ['unstamped row claimed by local', {}, 'local' as const, true, true, 0], - ['unstamped row claimed by SSH', {}, 'ssh:t' as const, true, true, 1], - ['unstamped row claimed by a server', {}, 'runtime:e' as const, true, true, 1], - ['unstamped row refused by SSH', {}, 'ssh:t' as const, false, false, 0], - ['unstamped row, no hint, SSH', {}, 'ssh:t' as const, undefined, false, 0], - ['unstamped row, no hint, local', {}, 'local' as const, undefined, true, 0] - ])('%s', (_name, row, hostId, unhostedWorktreesMatchHost, matches, count) => { + ['stamped SSH row', { hostId: 'ssh:t' as const }, 'ssh:t' as const, true, true], + ['stamped server row', { runtimeOwnerEnvironmentId: 'e' }, 'runtime:e' as const, true, true], + ['unstamped row claimed by local', {}, 'local' as const, true, true], + ['unstamped row claimed by SSH', {}, 'ssh:t' as const, true, true], + ['unstamped row claimed by a server', {}, 'runtime:e' as const, true, true], + ['unstamped row refused by SSH', {}, 'ssh:t' as const, false, false], + ['unstamped row, no hint, SSH', {}, 'ssh:t' as const, undefined, false], + ['unstamped row, no hint, local', {}, 'local' as const, undefined, true] + ])('%s', (_name, row, hostId, unhostedWorktreesMatchHost, matches) => { expect(worktreeMatchesHost(row, hostId, { unhostedWorktreesMatchHost })).toBe(matches) - expect(getUnstampedNonLocalRowCount()).toBe(count) }) }) diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index a7a19a9cb96a..fe6cac134811 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -13,7 +13,6 @@ import { type ExecutionHostId } from '../../../../../../shared/execution-host' import { toVisibleWorktree } from './worktree-catalog-visibility' -import { noteUnstampedNonLocalRow } from '@/lib/unstamped-non-local-row-counter' import type { WorktreeHostMatchOptions, RepoHostSummary } from './worktree-slice-types' export function withRepoHostOwnership< @@ -189,7 +188,7 @@ export function worktreeMatchesHost( if (worktree.hostId) { return worktree.hostId === hostId } - return unhostedRowMatchesNonLocalHost(options.unhostedWorktreesMatchHost ?? false) + return options.unhostedWorktreesMatchHost ?? false } if (worktree.runtimeOwnerEnvironmentId) { return false @@ -200,14 +199,7 @@ export function worktreeMatchesHost( if (hostId === LOCAL_EXECUTION_HOST_ID) { return options.unhostedWorktreesMatchHost ?? true } - return unhostedRowMatchesNonLocalHost(options.unhostedWorktreesMatchHost ?? false) -} - -function unhostedRowMatchesNonLocalHost(matches: boolean): boolean { - if (matches) { - noteUnstampedNonLocalRow() - } - return matches + return options.unhostedWorktreesMatchHost ?? false } export function mergeWorktreesForHost< diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts index c067e3fafb21..7e00c0c8a7f9 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts @@ -1,14 +1,7 @@ -import type { AppState } from '../../../types' import { tryRuntimeTargetForWorktreeOwner } from './worktree-owner-target' import type { Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import { - resolveWorktreeOperationRoute, - resolveWorktreeOperationRouteForHost, - settingsForWorktreeOperationRoute -} from '@/lib/worktree-operation-route' import { isRuntimeSelectorNotFoundError } from './runtime-worktree-rpc-errors' import { persistWorktreeMeta } from '../metadata/worktree-meta-persist' import type { WorktreeSliceGet } from './worktree-slice-types' @@ -30,32 +23,6 @@ export function replaceWorktreeInRepoLists( } } -export function trySettingsForWorktreeOwner( - state: Pick< - AppState, - | 'repos' - | 'settings' - | 'worktreesByRepo' - | 'detectedWorktreesByRepo' - | 'folderWorkspaces' - | 'projectGroups' - | 'restoredRuntimeHostIdByWorkspaceSessionKey' - | 'runtimeEnvironments' - | 'runtimeEnvironmentCatalogHydrated' - | 'removedRuntimeEnvironmentIds' - >, - worktreeId: string, - executionHostId?: ExecutionHostId -): AppState['settings'] | null { - const route = executionHostId - ? resolveWorktreeOperationRouteForHost(state, worktreeId, executionHostId) - : resolveWorktreeOperationRoute(state, worktreeId) - if (!route) { - return null - } - return settingsForWorktreeOperationRoute(state.settings, route) -} - // Why: activity bumps fire on every PTY event, so an ambiguous workspace would warn continuously. // One line per workspace is enough to diagnose it (#10634). export const ambiguousOwnerWarnedWorktreeIds = new Set() From b53ba14fd36bc79adebc9c9058379628b5b156b5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:47:21 -0700 Subject: [PATCH 42/58] fix(linux): launch the AppImage terminal daemon through its own mount (#27435) Co-authored-by: Sergey Tiron <1ivi41337@gmail.com> --- .../daemon/daemon-appimage-launch.test.ts | 132 ++++++++++++++++++ src/main/daemon/daemon-appimage-launch.ts | 127 +++++++++++++++++ .../daemon-launched-child-identity.test.ts | 105 ++++++++++++++ .../daemon-launched-child-spawn.test.ts | 45 ++++++ .../daemon/daemon-launched-child-spawn.ts | 40 ++++-- src/main/daemon/daemon-launched-child.ts | 44 +++++- ...n-out-of-process-launcher-identity.test.ts | 51 +++++++ .../daemon/daemon-out-of-process-launcher.ts | 17 ++- 8 files changed, 543 insertions(+), 18 deletions(-) create mode 100644 src/main/daemon/daemon-appimage-launch.test.ts create mode 100644 src/main/daemon/daemon-appimage-launch.ts create mode 100644 src/main/daemon/daemon-out-of-process-launcher-identity.test.ts diff --git a/src/main/daemon/daemon-appimage-launch.test.ts b/src/main/daemon/daemon-appimage-launch.test.ts new file mode 100644 index 000000000000..55b54be6eaf7 --- /dev/null +++ b/src/main/daemon/daemon-appimage-launch.test.ts @@ -0,0 +1,132 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '@orca/process-host' +import { buildAppImageDaemonCommand, resolveAppImageDaemonLaunch } from './daemon-appimage-launch' + +const { resolveIdentity } = vi.hoisted(() => ({ resolveIdentity: vi.fn() })) +vi.mock('../appimage-runtime-identity', () => ({ resolveAppImageRuntimeIdentity: resolveIdentity })) + +const APPDIR = '/tmp/.mount_orcaAbC' +const FUSE_MOUNTINFO = `953 65 0:108 / ${APPDIR} ro,nosuid,nodev,relatime shared:1 - fuse.Orca.AppImage Orca.AppImage ro,user_id=1000` +const INPUT = { + entryPath: `${APPDIR}/resources/app.asar.unpacked/out/main/daemon-entry.js`, + appPath: `${APPDIR}/resources/app.asar`, + appVersion: '1.4.215', + environment: { APPIMAGE: '/apps/Orca.AppImage', APPDIR }, + readMountInfo: () => FUSE_MOUNTINFO +} + +afterEach(() => resolveIdentity.mockReset()) + +describe('resolveAppImageDaemonLaunch', () => { + it('resolves paths relative to APPDIR on a FUSE-mounted AppImage', () => { + resolveIdentity.mockReturnValue({ appImagePath: '/apps/Orca.AppImage' }) + expect(resolveAppImageDaemonLaunch(INPUT)).toEqual({ + appImagePath: '/apps/Orca.AppImage', + entryPathInAppDir: 'resources/app.asar.unpacked/out/main/daemon-entry.js', + appPathInAppDir: 'resources/app.asar', + appVersion: '1.4.215' + }) + }) + + it('keeps the in-mount launch off a validated AppImage', () => { + resolveIdentity.mockReturnValue(null) + expect(resolveAppImageDaemonLaunch(INPUT)).toBeNull() + }) + + it('keeps the in-mount launch for extract-and-run payloads', () => { + resolveIdentity.mockReturnValue({ appImagePath: '/apps/Orca.AppImage' }) + const extracted = `953 65 0:34 / /tmp rw - tmpfs tmpfs rw` + expect(resolveAppImageDaemonLaunch({ ...INPUT, readMountInfo: () => extracted })).toBeNull() + }) + + it('decodes escaped mount points', () => { + resolveIdentity.mockReturnValue({ appImagePath: '/apps/Orca.AppImage' }) + const spaced = '/tmp/a b' + const launch = resolveAppImageDaemonLaunch({ + ...INPUT, + entryPath: `${spaced}/resources/entry.js`, + appPath: `${spaced}/resources/app.asar`, + environment: { APPDIR: spaced }, + readMountInfo: () => '1 2 0:1 / /tmp/a\\040b ro - fuse.x x ro' + }) + expect(launch?.entryPathInAppDir).toBe('resources/entry.js') + }) +}) + +describe('buildAppImageDaemonCommand', () => { + const launch = { + appImagePath: '/apps/Orca.AppImage', + entryPathInAppDir: 'resources/entry.js', + appPathInAppDir: 'resources/app.asar', + appVersion: '1.4.215' + } + let root: string | undefined + + afterEach(() => { + if (root) { + rmSync(root, { recursive: true, force: true }) + root = undefined + } + }) + + // Why runIf: the env scrub it reuses is a no-op off Linux. + it.runIf(process.platform === 'linux')( + 'drops the parent mount from the inherited loader and PATH entries', + () => { + const { command, args, env } = buildAppImageDaemonCommand(launch, ['--socket', '/s'], { + APPDIR, + APPIMAGE: '/apps/Orca.AppImage', + PATH: `${APPDIR}:${APPDIR}/usr/sbin:/usr/bin`, + LD_LIBRARY_PATH: `${APPDIR}/usr/lib`, + HOME: '/home/u' + }) + expect(command).toBe('/apps/Orca.AppImage') + expect(args.slice(2)).toEqual(['--', '--socket', '/s', '--no-sandbox']) + expect(env).toEqual({ PATH: '/usr/bin', HOME: '/home/u' }) + } + ) + + function runBootstrap(packagedVersion: string): { + result: ReturnType + appDir: string + } { + const appDir = mkdtempSync(join(tmpdir(), 'orca-appimage-boot-')) + root = appDir + mkdirSync(join(appDir, 'resources', 'app.asar'), { recursive: true }) + writeFileSync( + join(appDir, 'resources', 'app.asar', 'package.json'), + JSON.stringify({ version: packagedVersion }) + ) + writeFileSync( + join(appDir, 'resources', 'entry.js'), + 'console.log(JSON.stringify(process.argv.slice(1)))' + ) + const { args } = buildAppImageDaemonCommand(launch, ['--socket', '/s'], {}) + const result = runProcessSync({ + program: process.execPath, + args, + env: { ...process.env, APPDIR: appDir, ELECTRON_RUN_AS_NODE: '1' } + }) + return { result, appDir } + } + + it('runs the entry from the child APPDIR with the daemon argv', () => { + const { result, appDir } = runBootstrap('1.4.215') + expect(result.code).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + join(appDir, 'resources', 'entry.js'), + '--socket', + '/s', + '--no-sandbox' + ]) + }) + + it('refuses an AppImage replaced by a different release', () => { + const { result } = runBootstrap('1.5.0') + expect(result.code).toBe(1) + expect(result.stderr).toContain('AppImage is 1.5.0, expected 1.4.215') + }) +}) diff --git a/src/main/daemon/daemon-appimage-launch.ts b/src/main/daemon/daemon-appimage-launch.ts new file mode 100644 index 000000000000..0e8b6ab1638f --- /dev/null +++ b/src/main/daemon/daemon-appimage-launch.ts @@ -0,0 +1,127 @@ +/** + * Launching the daemon through the AppImage file itself (#18200). + * + * Run from `$APPDIR/orca-ide`, the daemon executes out of the parent's FUSE mount, which the + * AppImage runtime tears down when the window process exits or the login session ends; the + * surviving daemon then SIGBUSes on its next cold page. Exec'ing `$APPIMAGE` instead gives the + * daemon its own runtime and mount, created inside the daemon's own scope and released when the + * daemon exits. The new mount's path is unknown to the parent, so a `-e` bootstrap resolves the + * entry against the `APPDIR` the child runtime exports. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { relativePathInsideRoot } from '../../shared/cross-platform-path' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' +import type { DurableDaemonScopeCommand } from './daemon-cgroup-scope' +import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' + +export type AppImageDaemonLaunch = { + appImagePath: string + /** Entry and app.asar paths relative to APPDIR, resolved again inside the child's own mount. */ + entryPathInAppDir: string + appPathInAppDir: string + appVersion: string +} + +type AppImageDaemonLaunchInput = { + entryPath: string + appPath: string + appVersion: string + environment?: NodeJS.ProcessEnv + readMountInfo?: () => string +} + +function decodeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)) + ) +} + +function isFuseMountPoint(mountInfo: string, mountPoint: string): boolean { + return mountInfo.split('\n').some((line) => { + const [mountFields, fsFields] = line.split(' - ') + const point = mountFields?.split(' ')[4] + return ( + point !== undefined && + decodeMountInfoPath(point) === mountPoint && + fsFields?.startsWith('fuse') === true + ) + }) +} + +/** Null keeps the in-mount launch: not a validated AppImage, or the payload is not on a FUSE + * mount (extract-and-run, where a second run would share and delete the same extraction dir). */ +export function resolveAppImageDaemonLaunch({ + entryPath, + appPath, + appVersion, + environment = process.env, + readMountInfo = () => readFileSync('/proc/self/mountinfo', 'utf8') +}: AppImageDaemonLaunchInput): AppImageDaemonLaunch | null { + const appDir = environment.APPDIR + if (!appDir) { + return null + } + const entryPathInAppDir = relativePathInsideRoot(appDir, entryPath) + const appPathInAppDir = relativePathInsideRoot(appDir, appPath) + if (!entryPathInAppDir || !appPathInAppDir) { + return null + } + // Why after the path checks: identity validation does several synchronous file reads. + const identity = resolveAppImageRuntimeIdentity({ environment }) + if (!identity) { + return null + } + try { + if (!isFuseMountPoint(readMountInfo(), appDir)) { + return null + } + } catch { + return null + } + return { appImagePath: identity.appImagePath, entryPathInAppDir, appPathInAppDir, appVersion } +} + +/** + * The `--entry-path` identity an own-mount daemon records. Why not the mount path: each launch + * mounts at a fresh random dir, so the next window could never match a surviving daemon. A new + * release at the same `$APPIMAGE` still gets replaced via the recorded `--app-version`. + */ +export function appImageDaemonIdentityPath(launch: AppImageDaemonLaunch): string { + return join(launch.appImagePath, launch.entryPathInAppDir) +} + +function buildBootstrap(launch: AppImageDaemonLaunch): string { + const version = JSON.stringify(launch.appVersion) + return [ + `const {join}=require('path')`, + `const v=require(join(process.env.APPDIR,${JSON.stringify(launch.appPathInAppDir)},'package.json')).version`, + // Why: the file at $APPIMAGE may already be a different release (manual replace, updater). + `if(v!==${version}){console.error('[daemon] AppImage is '+v+', expected '+${version});process.exit(1)}`, + `const e=join(process.env.APPDIR,${JSON.stringify(launch.entryPathInAppDir)})`, + `process.argv.splice(1,0,e)`, + `require(e)` + ].join(';') +} + +/** The program/args/env replacing `execPath entry ...scriptArgs` for an AppImage launch. */ +export function buildAppImageDaemonCommand( + launch: AppImageDaemonLaunch, + scriptArgs: string[], + env: NodeJS.ProcessEnv +): DurableDaemonScopeCommand { + const childEnv: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + childEnv[key] = value + } + } + // Why: AppRun prepends the new mount but keeps inherited entries, so drop the parent's mount. + removeAppImageRuntimeEnv(childEnv) + return { + command: launch.appImagePath, + // Why trailing --no-sandbox: without it AppRun injects one before the script, which Node rejects. + args: ['-e', buildBootstrap(launch), '--', ...scriptArgs, '--no-sandbox'], + env: childEnv + } +} diff --git a/src/main/daemon/daemon-launched-child-identity.test.ts b/src/main/daemon/daemon-launched-child-identity.test.ts index c34a53c2e142..d1762e0c4f97 100644 --- a/src/main/daemon/daemon-launched-child-identity.test.ts +++ b/src/main/daemon/daemon-launched-child-identity.test.ts @@ -171,4 +171,109 @@ describe('launchDaemonChild durable-scope fallback', () => { ) expect(spawnDaemonChildProcessMock).toHaveBeenCalledOnce() }) + + // Outside AppImage the cleanup veto must not apply: the unscoped retry stays as it was. + it('still retries unscoped when a scoped attempt and its cleanup both fail', async () => { + isDurableDaemonScopeSupportedMock.mockReturnValue(true) + const scoped = fakeDaemonChild(4242) + const unscoped = fakeDaemonChild(4343) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + spawnDaemonChildProcessMock.mockImplementation((_options, useDurableScope: boolean) => { + const child = useDurableScope ? scoped : unscoped + queueMicrotask(() => + child.emit( + 'message', + useDurableScope + ? { type: 'ready', startedAtMs: 1 } + : { type: 'ready', pid: 4343, startedAtMs: 1_000_000 } + ) + ) + return child + }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + }) + + try { + const launched = await launchDaemonChild(LAUNCH_OPTIONS) + + expect(spawnDaemonChildProcessMock.mock.calls.map(([, scope]) => scope)).toEqual([ + true, + false + ]) + expect(launched.identity.pid).toBe(4343) + } finally { + kill.mockRestore() + warn.mockRestore() + } + }) +}) + +describe('launchDaemonChild AppImage fallback', () => { + const APPIMAGE_LAUNCH = { + appImagePath: '/apps/Orca.AppImage', + entryPathInAppDir: 'resources/app.asar.unpacked/out/main/daemon-entry.js', + appPathInAppDir: 'resources/app.asar', + appVersion: '1.0.0' + } + + it('retries from the current mount under a scope name the failed attempt never used', async () => { + isDurableDaemonScopeSupportedMock.mockReturnValue(true) + const failed = fakeDaemonChild(4242) + const inMount = fakeDaemonChild(4343) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + spawnDaemonChildProcessMock.mockImplementation((options: DaemonChildSpawnOptions) => { + if (options.appImage) { + queueMicrotask(() => { + failed.exitCode = 1 + failed.emit('exit', 1) + }) + return failed + } + queueMicrotask(() => + inMount.emit('message', { type: 'ready', pid: 4343, startedAtMs: 1_000_000 }) + ) + return inMount + }) + + try { + const launched = await launchDaemonChild({ ...LAUNCH_OPTIONS, appImage: APPIMAGE_LAUNCH }) + + const attempts = spawnDaemonChildProcessMock.mock.calls.map(([options, scoped]) => ({ + appImage: options.appImage?.appImagePath, + launchNonce: options.launchNonce, + scoped + })) + expect(attempts).toEqual([ + { appImage: '/apps/Orca.AppImage', launchNonce: expect.any(String), scoped: true }, + { appImage: undefined, launchNonce: 'nonce-a', scoped: true } + ]) + // The nonce names the scope unit, so a lingering failed unit cannot block the retry. + expect(attempts[0].launchNonce).not.toBe('nonce-a') + expect(launched.identity).toMatchObject({ pid: 4343, launchNonce: 'nonce-a' }) + } finally { + warn.mockRestore() + } + }) + + it('does not retry beside an AppImage attempt whose cleanup failed', async () => { + isDurableDaemonScopeSupportedMock.mockReturnValue(true) + const alive = fakeDaemonChild(4242) + spawnDaemonChildProcessMock.mockImplementation(() => { + queueMicrotask(() => alive.emit('message', { type: 'ready', startedAtMs: 1 })) + return alive + }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + }) + + try { + const launch = launchDaemonChild({ ...LAUNCH_OPTIONS, appImage: APPIMAGE_LAUNCH }) + + await expect(launch).rejects.toBeInstanceOf(AggregateError) + expect(spawnDaemonChildProcessMock).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } + }) }) diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index 31eab8fb6a97..94e3ac8fccf4 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -50,6 +50,51 @@ describe('daemon launch scope ownership', () => { }) }) +describe('daemon launch through the AppImage file', () => { + const appImage = { + appImagePath: '/apps/Orca.AppImage', + entryPathInAppDir: 'resources/app.asar.unpacked/out/main/daemon-entry.js', + appPathInAppDir: 'resources/app.asar', + appVersion: '1.0.0' + } + + it('execs the AppImage inside the durable scope', () => { + spawnDaemonChildProcess({ ...options, appImage }, true) + expect(fork).not.toHaveBeenCalled() + const { program, args } = spawn.mock.calls[0][0] + expect(program).toBe('systemd-run') + const exec = args.indexOf('/apps/Orca.AppImage') + expect(args.slice(exec + 1, exec + 2)).toEqual(['-e']) + expect(args.slice(-2)).toEqual(['--fresh-daemon-scope', '--no-sandbox']) + }) + + it('spawns the AppImage directly on the unscoped fallback', () => { + spawnDaemonChildProcess({ ...options, appImage }, false) + expect(fork).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/apps/Orca.AppImage', + args: expect.not.arrayContaining(['--fresh-daemon-scope']), + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + ) + }) + + it('records an entry identity that survives the next mount', () => { + spawnDaemonChildProcess({ ...options, appImage }, false) + const { args } = spawn.mock.calls[0][0] + expect(args[args.indexOf('--entry-path') + 1]).toBe( + '/apps/Orca.AppImage/resources/app.asar.unpacked/out/main/daemon-entry.js' + ) + }) + + it('keeps the mount entry identity for the in-mount fallback', () => { + spawnDaemonChildProcess(options, true) + const { args } = spawn.mock.calls[0][0] + expect(args[args.indexOf('--entry-path') + 1]).toBe('/app/daemon-entry.js') + }) +}) + describe('daemon launch environment', () => { const originalBus = process.env.DBUS_SESSION_BUS_ADDRESS afterEach(() => { diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index d4b173e02d5a..246473425392 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -3,6 +3,11 @@ import { spawnProcess } from '@orca/process-host' import type { SpawnedProcess } from '@orca/process-host/process-spec' import { getAppEnvironment } from '../../shared/app-environment' import { removeChromiumDisabledSessionBus } from '../pty/chromium-session-bus-env' +import { + appImageDaemonIdentityPath, + buildAppImageDaemonCommand, + type AppImageDaemonLaunch +} from './daemon-appimage-launch' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' @@ -10,6 +15,8 @@ export type DaemonChildSpawnOptions = { entryPath: string forkEntryPath: string relocatedExecPath?: string + /** Linux AppImage: exec the AppImage file so the daemon owns its mount. */ + appImage?: AppImageDaemonLaunch userDataPath: string socketPath: string tokenPath: string @@ -21,7 +28,15 @@ export type DaemonChildSpawnOptions = { } function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { - const { socketPath, tokenPath, pidPath, launchNonce, entryPath, macosLoginSessionWatch } = options + const { + socketPath, + tokenPath, + pidPath, + launchNonce, + entryPath, + appImage, + macosLoginSessionWatch + } = options return [ '--socket', socketPath, @@ -32,7 +47,7 @@ function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { '--launch-nonce', launchNonce, '--entry-path', - entryPath, + appImage ? appImageDaemonIdentityPath(appImage) : entryPath, '--app-version', getAppEnvironment().getVersion(), '--spawner-exec-path', @@ -59,7 +74,7 @@ export function spawnDaemonChildProcess( options: DaemonChildSpawnOptions, useDurableScope: boolean ): SpawnedProcess { - const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options + const { forkEntryPath, relocatedExecPath, appImage, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. const daemonEnv: NodeJS.ProcessEnv = { @@ -77,7 +92,7 @@ export function spawnDaemonChildProcess( detached: true, stdio: ['ignore', 'ignore', 'pipe', 'ipc'] } - if (!useDurableScope) { + if (!useDurableScope && !appImage) { return forkProcess({ ...childOptions, modulePath: forkEntryPath, @@ -87,12 +102,17 @@ export function spawnDaemonChildProcess( env: daemonEnv }) } - const scoped = buildDurableDaemonScopeCommand( - relocatedExecPath ?? process.execPath, - [forkEntryPath, ...scriptArgs, '--fresh-daemon-scope'], - launchNonce, - daemonEnv - ) + const scopeArgs = useDurableScope ? ['--fresh-daemon-scope'] : [] + const direct = appImage + ? buildAppImageDaemonCommand(appImage, [...scriptArgs, ...scopeArgs], daemonEnv) + : { + command: relocatedExecPath ?? process.execPath, + args: [forkEntryPath, ...scriptArgs, ...scopeArgs], + env: daemonEnv + } + const scoped = useDurableScope + ? buildDurableDaemonScopeCommand(direct.command, direct.args, launchNonce, direct.env) + : direct return spawnProcess({ ...childOptions, program: scoped.command, diff --git a/src/main/daemon/daemon-launched-child.ts b/src/main/daemon/daemon-launched-child.ts index a474d242a014..63f454b70c64 100644 --- a/src/main/daemon/daemon-launched-child.ts +++ b/src/main/daemon/daemon-launched-child.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { isDurableDaemonScopeSupported } from './daemon-cgroup-scope' import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership' import type { DaemonEndpointIdentity } from './daemon-hello-protocol' @@ -24,11 +25,45 @@ export class DaemonEndpointUnavailableError extends Error { } } +/** The failed attempt may still be alive, so no retry may start beside it. */ +class DaemonStartupCleanupError extends AggregateError {} + export type LaunchedDaemonChild = { child: ChildProcess identity: DaemonEndpointIdentity } +export async function launchDaemonChild( + options: DaemonChildSpawnOptions +): Promise { + // Why once: the probe is a synchronous systemd-run spawn; the AppImage fallback must not repeat it. + const scopeSupported = isDurableDaemonScopeSupported() + if (options.appImage) { + try { + // Why a fresh nonce: it names the scope unit, and a failed attempt's unit may still hold its + // FUSE server when the in-mount retry registers the original name. + return await launchDaemonChildAttempt( + { ...options, launchNonce: randomUUID() }, + scopeSupported + ) + } catch (error) { + if ( + error instanceof DaemonEndpointUnavailableError || + error instanceof DaemonStartupCleanupError + ) { + throw error + } + // Why: a missing/replaced AppImage or a refused FUSE mount must not cost the daemon; the + // in-mount launch (with its own scope fallback) still works while this window lives. + console.warn( + '[daemon] AppImage launch failed, retrying from the current mount:', + error instanceof Error ? error.message : String(error) + ) + } + } + return launchDaemonChildInEitherScope({ ...options, appImage: undefined }, scopeSupported) +} + /** * Why a wrapper instead of one code path: a durable cgroup scope (see `daemon-cgroup-scope.ts`) * is what lets the daemon survive a combined-unit `systemctl restart`, but the pre-flight @@ -37,10 +72,11 @@ export type LaunchedDaemonChild = { * for any reason but a lost endpoint race retries once, unscoped, so an environment that cannot * support isolation degrades to today's proven behavior instead of failing the launch outright. */ -export async function launchDaemonChild( - options: DaemonChildSpawnOptions +async function launchDaemonChildInEitherScope( + options: DaemonChildSpawnOptions, + scopeSupported: boolean ): Promise { - if (!isDurableDaemonScopeSupported()) { + if (!scopeSupported) { return launchDaemonChildAttempt(options, false) } try { @@ -118,7 +154,7 @@ async function launchDaemonChildAttempt( await terminateLaunchedDaemonChild(child) } catch (cleanupError) { reject( - new AggregateError( + new DaemonStartupCleanupError( [startupError, cleanupError], 'Daemon startup and child cleanup both failed' ) diff --git a/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts b/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts new file mode 100644 index 000000000000..ec284787aeb9 --- /dev/null +++ b/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOutOfProcessLauncher } from './daemon-out-of-process-launcher' +import type * as AppImageLaunch from './daemon-appimage-launch' + +const { prepare, resolveLaunch } = vi.hoisted(() => ({ + prepare: vi.fn(), + resolveLaunch: vi.fn() +})) +vi.mock('./daemon-replacement-preflight', () => ({ prepareDaemonReplacement: prepare })) +vi.mock('./daemon-appimage-launch', async (importOriginal) => ({ + ...(await importOriginal()), + resolveAppImageDaemonLaunch: resolveLaunch +})) +vi.mock('./daemon-launch-paths', () => ({ + getDaemonEntryPath: () => '/tmp/.mount_orcaAbC/resources/entry.js', + probeDaemonSocket: vi.fn() +})) +vi.mock('./client', () => ({ + DaemonClient: class { + ensureConnectedWithin = (): Promise => Promise.reject(new Error('no daemon')) + disconnect = (): void => {} + } +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getAppPath: () => '/app', getVersion: () => '1.0.0' }) +})) + +afterEach(() => vi.clearAllMocks()) + +async function expectedIdentity(): Promise { + prepare.mockResolvedValue({ adopted: true, shutdown: vi.fn() }) + await createOutOfProcessLauncher('/run')('/run/sock', '/run/token') + return prepare.mock.calls[0][0].entryPath +} + +describe('daemon launch identity', () => { + it('matches a surviving own-mount daemon by its AppImage file', async () => { + resolveLaunch.mockReturnValue({ + appImagePath: '/apps/Orca.AppImage', + entryPathInAppDir: 'resources/entry.js', + appPathInAppDir: 'resources/app.asar', + appVersion: '1.0.0' + }) + expect(await expectedIdentity()).toBe('/apps/Orca.AppImage/resources/entry.js') + }) + + it('keeps the entry path everywhere else', async () => { + resolveLaunch.mockReturnValue(null) + expect(await expectedIdentity()).toBe('/tmp/.mount_orcaAbC/resources/entry.js') + }) +}) diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 9ac31e4206ba..b0ea60bd67a0 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -15,6 +15,7 @@ import { } from './daemon-launched-child' import { getDaemonEntryPath, probeDaemonSocket as probeSocket } from './daemon-launch-paths' import { materializeRelocatedDaemonHost, pruneDaemonHostStaging } from './daemon-host-relocation' +import { appImageDaemonIdentityPath, resolveAppImageDaemonLaunch } from './daemon-appimage-launch' import { DAEMON_RECOVERY_BUDGET_MS, daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' import { cleanupDaemonForProtocol } from './daemon-protocol-cleanup' import { @@ -105,11 +106,18 @@ export function createOutOfProcessLauncher( }) } try { + const environment = getAppEnvironment() + const appImage = resolveAppImageDaemonLaunch({ + entryPath, + appPath: environment.getAppPath(), + appVersion: environment.getVersion() + }) const preservedHandle = await prepareDaemonReplacement({ runtimeDir, socketPath, tokenPath, - entryPath, + // Why: must equal the identity the own-mount daemon records, or a survivor never matches. + entryPath: appImage ? appImageDaemonIdentityPath(appImage) : entryPath, recoveryDeadlineMs, attributedReason, releaseAdoptionClient, @@ -120,7 +128,7 @@ export function createOutOfProcessLauncher( return preservedHandle } - const userDataPath = getAppEnvironment().getPath('userData') + const userDataPath = environment.getPath('userData') // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. const relocatedHost = materializeRelocatedDaemonHost() pruneDaemonHostStaging() @@ -132,6 +140,7 @@ export function createOutOfProcessLauncher( entryPath, forkEntryPath, relocatedExecPath: relocatedHost?.execPath, + appImage: relocatedHost ? undefined : (appImage ?? undefined), userDataPath, socketPath, tokenPath, @@ -166,7 +175,7 @@ export function createOutOfProcessLauncher( } catch (error) { if (error instanceof DaemonEndpointOwnershipError) { await terminateLaunchedDaemonChild(launched.child) - unlinkOwnedDaemonPidFile(pidPath, launched.identity.pid, launchNonce) + unlinkOwnedDaemonPidFile(pidPath, launched.identity.pid, launched.identity.launchNonce) throw error } // Why: another client may have adopted this live process; keep its pid record until exit, but remove one published after an early exit. @@ -176,7 +185,7 @@ export function createOutOfProcessLauncher( return } pidRecordRemoved = true - unlinkOwnedDaemonPidFile(pidPath, launched.identity.pid, launchNonce) + unlinkOwnedDaemonPidFile(pidPath, launched.identity.pid, launched.identity.launchNonce) } launched.child.once('exit', removeExitedPidRecord) if (childProcessHasExited(launched.child)) { From be594621344457560e32359aecf1df3ac39b035a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 02:55:29 -0700 Subject: [PATCH 43/58] feat(daemon): pace automatic terminal restores on headless hosts (#27428) * feat(daemon): pace automatic terminal restores on headless hosts * fix(daemon): cap the paced-restore window below remote client timeouts --------- Co-authored-by: m4air --- .../daemon/daemon-launched-child-spawn.ts | 3 + src/main/daemon/daemon-main.ts | 4 + .../daemon/daemon-out-of-process-launcher.ts | 11 +- .../daemon-pty-adapter-spawn-intent.test.ts | 52 +++++ src/main/daemon/daemon-pty-spawn-request.ts | 1 + src/main/daemon/daemon-server-options.ts | 3 + src/main/daemon/daemon-server.ts | 5 +- src/main/daemon/daemon-terminal-admission.ts | 12 +- .../daemon/terminal-host-session-create.ts | 32 +++- src/main/daemon/terminal-spawn-gate.test.ts | 97 +++++++++- src/main/daemon/terminal-spawn-gate.ts | 181 +++++++++++++----- src/main/daemon/types.ts | 3 + .../orcad/orcad-daemon-supervision.test.ts | 4 +- src/main/orcad/orcad-daemon-supervision.ts | 1 + src/main/providers/pty-provider-contract.ts | 3 + src/main/startup/main-process-pty-startup.ts | 4 +- src/shared/host-spawn-admission.ts | 34 +++- 17 files changed, 379 insertions(+), 71 deletions(-) create mode 100644 src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 246473425392..2093c94c90db 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -10,6 +10,7 @@ import { } from './daemon-appimage-launch' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' +import { TERMINAL_SPAWN_ADMISSION_ARG } from './terminal-spawn-gate' export type DaemonChildSpawnOptions = { entryPath: string @@ -23,6 +24,7 @@ export type DaemonChildSpawnOptions = { pidPath: string launchNonce: string macosLoginSessionWatch: boolean + spawnAdmission?: 'observe' | 'enforce' /** Defaults to 10 s; a cold first exec on Windows can need longer. */ startupTimeoutMs?: number } @@ -53,6 +55,7 @@ function buildDaemonScriptArgs(options: DaemonChildSpawnOptions): string[] { '--spawner-exec-path', process.execPath, ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), + ...(options.spawnAdmission === 'enforce' ? [TERMINAL_SPAWN_ADMISSION_ARG, 'enforce'] : []), ...daemonLogArgs() ] } diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index b2683b0af999..add2bc7ad808 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,6 +1,7 @@ import { DaemonServer } from './daemon-server' import type { DaemonServerOptions } from './daemon-server-options' import type { DaemonFileLog } from './daemon-file-log' +import { readTerminalSpawnAdmissionArg } from './terminal-spawn-gate' export type DaemonStartOptions = { socketPath: string @@ -19,6 +20,7 @@ export type DaemonStartOptions = { onPtySessionExit?: DaemonServerOptions['onPtySessionExit'] onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair'] log?: DaemonFileLog + spawnAdmission?: DaemonServerOptions['spawnAdmission'] onIdleShutdown?: () => void onRpcShutdown?: () => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] @@ -49,6 +51,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise { const entryPath = getDaemonEntryPath() @@ -147,7 +151,8 @@ export function createOutOfProcessLauncher( pidPath, launchNonce, macosLoginSessionWatch, - startupTimeoutMs + startupTimeoutMs, + ...(spawnAdmission ? { spawnAdmission } : {}) }) } catch (error) { if (!(error instanceof DaemonEndpointUnavailableError) || error.reason !== 'occupied') { diff --git a/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts b/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts new file mode 100644 index 000000000000..b50c57f89401 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-spawn-intent.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { readTerminalSpawnAdmissionArg } from './terminal-spawn-gate' + +function createPayloads(requestSpy: { mock: { calls: unknown[][] } }): unknown[] { + return requestSpy.mock.calls.filter(([type]) => type === 'createOrAttach').map(([, p]) => p) +} + +describe('daemon spawn intent wire', () => { + it('forwards a declared intent on create and omits it on attach-only', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + }) + const adapter = new DaemonPtyAdapter({ + socketPath: '/tmp/orca-intent.sock', + tokenPath: '/tmp/orca-intent.token' + }) + try { + await adapter.spawn({ + sessionId: 'intent', + cols: 80, + rows: 24, + spawnIntent: 'automatic-restore' + }) + await adapter.spawn({ sessionId: 'plain', cols: 80, rows: 24 }) + const [declared, undeclared] = createPayloads(requestSpy) + expect(declared).toHaveProperty('spawnIntent', 'automatic-restore') + expect(undeclared).not.toHaveProperty('spawnIntent') + } finally { + adapter.dispose() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + + it('reads the launcher flag and observes for anything else', () => { + expect(readTerminalSpawnAdmissionArg(['node', 'entry', '--spawn-admission', 'enforce'])).toBe( + 'enforce' + ) + expect(readTerminalSpawnAdmissionArg(['node', 'entry', '--spawn-admission', 'bogus'])).toBe( + 'observe' + ) + expect(readTerminalSpawnAdmissionArg(['node', 'entry'])).toBe('observe') + }) +}) diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 1da17c63da95..11f8b397dd6c 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -132,6 +132,7 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + ...(!context.attachOnly && opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), ...(!context.attachOnly && opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } : {}) diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts index 06f2cda6cb96..ed849a73e9d6 100644 --- a/src/main/daemon/daemon-server-options.ts +++ b/src/main/daemon/daemon-server-options.ts @@ -1,5 +1,6 @@ import type { DaemonFileLog } from './daemon-file-log' import type { SubprocessHandle } from './session-subprocess-handle' +import type { TerminalSpawnAdmissionMode } from './terminal-spawn-gate' export type DaemonServerOptions = { socketPath: string @@ -27,6 +28,8 @@ export type DaemonServerOptions = { onPtySessionExit?: (sessionId: string) => void onAuthenticatedClientPair?: () => void log?: DaemonFileLog + /** Headless hosts enforce; desktop only observes until the client handles deferral. */ + spawnAdmission?: TerminalSpawnAdmissionMode spawnSubprocess: (opts: { sessionId: string cols: number diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7ee6805bad72..44288a266b62 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -51,7 +51,10 @@ export class DaemonServer { this.log = options.log ?? createNoopDaemonFileLog() this.host = new TerminalHost({ spawnSubprocess: options.spawnSubprocess, - spawnGate: new TerminalSpawnGate({ log: (event, details) => this.log.log(event, details) }), + spawnGate: new TerminalSpawnGate({ + log: (event, details) => this.log.log(event, details), + mode: options.spawnAdmission ?? 'observe' + }), reportReadinessEvent: (event, details) => this.log.log(event, details), onSessionReaped: (sessionId) => { this.attachments.release(sessionId) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 950592958e5e..04daad020155 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -14,6 +14,7 @@ import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host' import { isTuiAgent } from '../../shared/tui-agent-config' +import { isSpawnIntent } from '../../shared/host-spawn-admission' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import { isAgentSessionExecutionClaim, @@ -23,14 +24,15 @@ import { type DaemonRequest, TerminalAttachCanceledError } from './types' type CreateOrAttachRequest = Extract -const DEFAULT_SPAWN_ADMISSION_WINDOW_MS = 15_000 +const MAX_SPAWN_ADMISSION_WINDOW_MS = 12_000 -// Why half: a paced fork must still finish before the caller's own cancellation fires. +// Why half, capped: a paced fork must finish before the caller's own cancellation fires, +// and remote clients give up on a terminal create after 15 s. function resolveSpawnAdmissionDeadline(cancelAfterMs: unknown): number { const windowMs = Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0 - ? Math.min(Number(cancelAfterMs), 300_000) / 2 - : DEFAULT_SPAWN_ADMISSION_WINDOW_MS + ? Math.min(Number(cancelAfterMs) / 2, MAX_SPAWN_ADMISSION_WINDOW_MS) + : MAX_SPAWN_ADMISSION_WINDOW_MS return Date.now() + windowMs } @@ -128,7 +130,7 @@ export class DaemonTerminalAdmission { ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), spawnAdmission: { clientId, - declaredIntent: null, + declaredIntent: isSpawnIntent(payload.spawnIntent) ? payload.spawnIntent : null, deadlineAt: resolveSpawnAdmissionDeadline(payload.cancelAfterMs) }, isCanceled: () => spawnPreparation?.canceled === true, diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 35e2dd765ac4..55ac431673d5 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -109,16 +109,30 @@ export async function createOrAttachTerminalSession( deps.killedTombstones.clearForCreate(opts.sessionId) const size = normalizePtySize(opts.cols, opts.rows) const wslDistro = resolveWslSessionContext(opts)?.distro - const lease = deps.spawnGate?.admit({ - clientId: opts.spawnAdmission?.clientId ?? 'in-process', - declaredIntent: opts.spawnAdmission?.declaredIntent ?? null, - hasAgentSessionClaim: opts.agentSessionEnsure !== undefined, - hasHistorySeed: (opts.historySeedChunks?.length ?? 0) > 0, - deadlineAt: opts.spawnAdmission?.deadlineAt ?? Number.POSITIVE_INFINITY - }) + const sessionId = opts.sessionId + let lease = deps.spawnGate?.admit( + { + clientId: opts.spawnAdmission?.clientId ?? 'in-process', + declaredIntent: opts.spawnAdmission?.declaredIntent ?? null, + hasAgentSessionClaim: opts.agentSessionEnsure !== undefined, + hasHistorySeed: (opts.historySeedChunks?.length ?? 0) > 0, + deadlineAt: opts.spawnAdmission?.deadlineAt ?? Number.POSITIVE_INFINITY + }, + opts.cancelSignal + ? { signal: opts.cancelSignal, error: () => new TerminalAttachCanceledError(sessionId) } + : undefined + ) + const paced = lease instanceof Promise + if (lease instanceof Promise) { + lease = await lease + } try { - return await spawnAndPublishSession(opts, deps, { size, wslDistro }, (sessionId, isAlive) => - lease?.commit(sessionId, isAlive) + if (paced) { + // Why: a paced wait can outlast the host's shutdown fence or the caller's cancellation. + deps.assertCreateAllowed() + } + return await spawnAndPublishSession(opts, deps, { size, wslDistro }, (publishedId, isAlive) => + lease?.commit(publishedId, isAlive) ) } finally { lease?.abandon() diff --git a/src/main/daemon/terminal-spawn-gate.test.ts b/src/main/daemon/terminal-spawn-gate.test.ts index b6bde17049f7..f049df8d72bc 100644 --- a/src/main/daemon/terminal-spawn-gate.test.ts +++ b/src/main/daemon/terminal-spawn-gate.test.ts @@ -2,7 +2,8 @@ import './mock-descendant-sweep' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { SubprocessHandle } from './session-subprocess-handle' import { TerminalHost, type TerminalHostOptions } from './terminal-host' -import { TerminalSpawnGate } from './terminal-spawn-gate' +import { TerminalSpawnGate, type TerminalSpawnAdmissionMode } from './terminal-spawn-gate' +import type { SpawnIntent } from '../../shared/host-spawn-admission' type SpawnSubprocess = TerminalHostOptions['spawnSubprocess'] @@ -10,7 +11,7 @@ function stream() { return { onData: vi.fn(), onExit: vi.fn() } } -describe('TerminalSpawnGate (observe-only)', () => { +describe('TerminalSpawnGate', () => { let host: TerminalHost let gate: TerminalSpawnGate let log: Mock<(event: string, details: Record) => void> @@ -18,6 +19,7 @@ describe('TerminalSpawnGate (observe-only)', () => { let spawnSubprocess: Mock let nextPid: number let now: number + let useGate: (mode: TerminalSpawnAdmissionMode) => void beforeEach(() => { exits = new Map() @@ -25,6 +27,10 @@ describe('TerminalSpawnGate (observe-only)', () => { now = 0 log = vi.fn() gate = new TerminalSpawnGate({ log, now: () => now }) + useGate = (mode) => { + gate = new TerminalSpawnGate({ log, mode, now: () => Date.now() }) + host = new TerminalHost({ spawnSubprocess, spawnGate: gate }) + } spawnSubprocess = vi.fn(() => { const pid = nextPid++ const handle: SubprocessHandle = { @@ -48,10 +54,14 @@ describe('TerminalSpawnGate (observe-only)', () => { }) afterEach(async () => { + vi.useRealTimers() await host.dispose() }) - function restore(sessionId: string) { + function restore( + sessionId: string, + admission: { declaredIntent?: SpawnIntent; deadlineAt?: number; signal?: AbortSignal } = {} + ) { return host.createOrAttach({ sessionId, cols: 80, @@ -72,7 +82,12 @@ describe('TerminalSpawnGate (observe-only)', () => { terminalHandle: `term_${sessionId}` } }, - spawnAdmission: { clientId: 'client-a', declaredIntent: null, deadlineAt: Infinity }, + spawnAdmission: { + clientId: 'client-a', + declaredIntent: admission.declaredIntent ?? null, + deadlineAt: admission.deadlineAt ?? Infinity + }, + ...(admission.signal ? { cancelSignal: admission.signal } : {}), streamClient: stream() }) } @@ -83,7 +98,7 @@ describe('TerminalSpawnGate (observe-only)', () => { } expect(spawnSubprocess).toHaveBeenCalledTimes(5) - expect(gate.snapshot()).toMatchObject({ liveAutomatic: 5, wouldWait: 2, wouldDefer: 0 }) + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 5, waited: 2, deferred: 0 }) expect(log).toHaveBeenCalledWith( 'spawn-admission-observed', expect.objectContaining({ decision: 'wait', charge: 'automatic' }) @@ -173,4 +188,76 @@ describe('TerminalSpawnGate (observe-only)', () => { await expect(pending).rejects.toThrow(/canceled/i) expect(gate.snapshot()).toMatchObject({ liveAutomatic: 0, inFlight: { automatic: 0 } }) }) + + describe('enforce', () => { + beforeEach(() => { + vi.useFakeTimers({ now: 0 }) + useGate('enforce') + }) + + it('paces an undeclared restore storm to the burst, then one per second', async () => { + const results = Array.from({ length: 5 }, (_, i) => restore(`storm-${i}`)) + await vi.advanceTimersByTimeAsync(0) + expect(spawnSubprocess).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(1000) + expect(spawnSubprocess).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1000) + expect(spawnSubprocess).toHaveBeenCalledTimes(5) + await Promise.all(results) + expect(gate.snapshot()).toMatchObject({ liveAutomatic: 5, waiting: 0, waited: 2 }) + }) + + it('admits an undeclared restore at its deadline instead of refusing it', async () => { + for (let i = 0; i < 3; i++) { + await restore(`burst-${i}`) + } + const late = restore('late', { deadlineAt: 400 }) + await vi.advanceTimersByTimeAsync(400) + await expect(late).resolves.toMatchObject({ isNew: true }) + expect(spawnSubprocess).toHaveBeenCalledTimes(4) + }) + + it('tells a declared caller to keep its pane sleeping beyond the live cap', async () => { + for (let i = 0; i < 8; i++) { + await restore(`live-${i}`, { declaredIntent: 'automatic-restore' }) + await vi.advanceTimersByTimeAsync(1000) + } + await expect(restore('ninth', { declaredIntent: 'automatic-restore' })).rejects.toThrow( + 'terminal_spawn_deferred:live_cap' + ) + expect(spawnSubprocess).toHaveBeenCalledTimes(8) + + exits.get(1000)?.(0) + await expect( + restore('ninth', { declaredIntent: 'automatic-restore' }) + ).resolves.toMatchObject({ isNew: true }) + }) + + it('never queues an interactive shell behind a restore storm', async () => { + for (let i = 0; i < 3; i++) { + await restore(`burst-${i}`) + } + const waiting = restore('waiting') + await expect( + host.createOrAttach({ sessionId: 'shell', cols: 80, rows: 24, streamClient: stream() }) + ).resolves.toMatchObject({ isNew: true }) + await vi.advanceTimersByTimeAsync(1000) + await waiting + }) + + it('leaves the queue without forking when the caller cancels', async () => { + for (let i = 0; i < 3; i++) { + await restore(`burst-${i}`) + } + const controller = new AbortController() + const canceled = restore('canceled', { signal: controller.signal }) + const assertion = expect(canceled).rejects.toThrow(/canceled/i) + await vi.advanceTimersByTimeAsync(100) + controller.abort() + await assertion + expect(spawnSubprocess).toHaveBeenCalledTimes(3) + expect(gate.snapshot()).toMatchObject({ waiting: 0, inFlight: { automatic: 0 } }) + }) + }) }) diff --git a/src/main/daemon/terminal-spawn-gate.ts b/src/main/daemon/terminal-spawn-gate.ts index 448bac805f91..8c72b4f12277 100644 --- a/src/main/daemon/terminal-spawn-gate.ts +++ b/src/main/daemon/terminal-spawn-gate.ts @@ -7,6 +7,8 @@ import { releaseLiveAutomatic, resolveSpawnCharge, settleSpawnAdmission, + TERMINAL_SPAWN_DEFERRED_PREFIX, + TERMINAL_SPAWN_REFUSED_MESSAGE, type AutomaticWorkMode, type HostPressureLevel, type SpawnAdmissionDecision, @@ -16,6 +18,16 @@ import { type SpawnRequestFacts } from '../../shared/host-spawn-admission' +export type TerminalSpawnAdmissionMode = 'observe' | 'enforce' + +export const TERMINAL_SPAWN_ADMISSION_ARG = '--spawn-admission' + +/** Reads the launcher's `--spawn-admission `; anything else observes. */ +export function readTerminalSpawnAdmissionArg(argv: readonly string[]): TerminalSpawnAdmissionMode { + const index = argv.indexOf(TERMINAL_SPAWN_ADMISSION_ARG) + return index !== -1 && argv[index + 1] === 'enforce' ? 'enforce' : 'observe' +} + export type TerminalSpawnLease = { readonly charge: SpawnCharge /** The fork published a session; only a live automatic one holds a live-cap slot. */ @@ -25,16 +37,25 @@ export type TerminalSpawnLease = { } export type TerminalSpawnGateSnapshot = { - mode: 'observe' + mode: TerminalSpawnAdmissionMode inFlight: Record liveAutomatic: number - wouldWait: number - wouldDefer: number - wouldRefuse: number + waiting: number + waited: number + deferred: number + refused: number +} + +export class TerminalSpawnDeferredError extends Error { + constructor(readonly reason: string) { + super(`${TERMINAL_SPAWN_DEFERRED_PREFIX}${reason}`) + this.name = 'TerminalSpawnDeferredError' + } } type TerminalSpawnGateOptions = { log: (event: string, details: Record) => void + mode?: TerminalSpawnAdmissionMode limits?: SpawnAdmissionLimits now?: () => number pressure?: () => HostPressureLevel @@ -42,22 +63,26 @@ type TerminalSpawnGateOptions = { } /** - * The daemon's single fork gate. Observe-only for now: every request is admitted - * immediately and the decision the policy would have made is counted and logged, - * so the budget defaults can be tuned from real numbers before anything waits. + * The daemon's single fork gate. In `observe` every request is admitted at once and + * the policy's verdict is only counted. In `enforce` automatic restores wait for a + * slot inside the caller's deadline; a caller that declared an intent may instead be + * told to keep its pane sleeping, and an undeclared caller is only ever paced. */ export class TerminalSpawnGate { + private readonly mode: TerminalSpawnAdmissionMode private readonly limits: SpawnAdmissionLimits private readonly now: () => number private readonly pressure: () => HostPressureLevel private readonly automaticWorkMode: () => AutomaticWorkMode private readonly state: SpawnAdmissionState private readonly liveAutomaticSessions = new Set() - private wouldWait = 0 - private wouldDefer = 0 - private wouldRefuse = 0 + private readonly waiters = new Set<() => void>() + private waited = 0 + private deferred = 0 + private refused = 0 constructor(private readonly options: TerminalSpawnGateOptions) { + this.mode = options.mode ?? 'observe' this.limits = options.limits ?? DEFAULT_SPAWN_ADMISSION_LIMITS this.now = options.now ?? Date.now this.pressure = options.pressure ?? (() => 'unknown') @@ -65,7 +90,37 @@ export class TerminalSpawnGate { this.state = createSpawnAdmissionState(this.limits, this.now()) } - admit(facts: SpawnRequestFacts): TerminalSpawnLease { + /** Returns synchronously unless the request has to wait, so an unpaced fork keeps + * today's timing against the host's shutdown fence. */ + admit( + facts: SpawnRequestFacts, + cancel?: { signal: AbortSignal; error: () => Error } + ): TerminalSpawnLease | Promise { + const attempt = this.attempt(facts, cancel, false) + return attempt.kind === 'lease' ? attempt.lease : this.admitAfterWait(facts, cancel, attempt) + } + + private async admitAfterWait( + facts: SpawnRequestFacts, + cancel: { signal: AbortSignal; error: () => Error } | undefined, + first: { kind: 'wait'; untilMs: number } + ): Promise { + let attempt: ReturnType = first + while (attempt.kind === 'wait') { + await this.waitForChange(attempt.untilMs, cancel?.signal) + attempt = this.attempt(facts, cancel, true) + } + return attempt.lease + } + + private attempt( + facts: SpawnRequestFacts, + cancel: { signal: AbortSignal; error: () => Error } | undefined, + alreadyWaited: boolean + ): { kind: 'lease'; lease: TerminalSpawnLease } | { kind: 'wait'; untilMs: number } { + if (cancel?.signal.aborted) { + throw cancel.error() + } const now = this.now() pruneUserResumeBuckets(this.state, this.limits, now) const charge = resolveSpawnCharge(this.state, facts, this.limits, now) @@ -78,66 +133,102 @@ export class TerminalSpawnGate { limits: this.limits, now }) - this.recordObservation(decision, charge, facts) - applySpawnAdmission(this.state, facts, charge, this.limits, now) - let settled = false - return { - charge, - commit: (sessionId, isAlive) => { - if (settled) { - return - } - settled = true - const becameLive = charge === 'automatic' && isAlive - settleSpawnAdmission(this.state, charge, becameLive) - if (becameLive) { - this.liveAutomaticSessions.add(sessionId) - } - }, - abandon: () => { - if (settled) { - return - } - settled = true - settleSpawnAdmission(this.state, charge, false) - } + if (this.mode === 'observe' || decision.kind === 'admit') { + this.record(decision, charge, facts, !alreadyWaited) + return { kind: 'lease', lease: this.lease(facts, charge, now) } + } + this.record(decision, charge, facts, !alreadyWaited) + if (decision.kind === 'defer') { + throw new TerminalSpawnDeferredError(decision.reason) } + if (decision.kind === 'refuse_retryable') { + throw new Error(TERMINAL_SPAWN_REFUSED_MESSAGE) + } + return { kind: 'wait', untilMs: Math.min(decision.retryAt ?? Infinity, facts.deadlineAt) } } onSessionExit(sessionId: string): void { if (this.liveAutomaticSessions.delete(sessionId)) { releaseLiveAutomatic(this.state) + this.wakeWaiters() } } snapshot(): TerminalSpawnGateSnapshot { return { - mode: 'observe', + mode: this.mode, inFlight: { ...this.state.inFlight }, liveAutomatic: this.state.liveAutomatic, - wouldWait: this.wouldWait, - wouldDefer: this.wouldDefer, - wouldRefuse: this.wouldRefuse + waiting: this.waiters.size, + waited: this.waited, + deferred: this.deferred, + refused: this.refused + } + } + + private lease(facts: SpawnRequestFacts, charge: SpawnCharge, now: number): TerminalSpawnLease { + applySpawnAdmission(this.state, facts, charge, this.limits, now) + let settled = false + const settle = (sessionId: string | null, isAlive: boolean): void => { + if (settled) { + return + } + settled = true + const becameLive = charge === 'automatic' && isAlive && sessionId !== null + settleSpawnAdmission(this.state, charge, becameLive) + if (becameLive) { + this.liveAutomaticSessions.add(sessionId) + } + this.wakeWaiters() + } + return { + charge, + commit: (sessionId, isAlive) => settle(sessionId, isAlive), + abandon: () => settle(null, false) + } + } + + private waitForChange(untilMs: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', done) + this.waiters.delete(done) + resolve() + } + const delayMs = Number.isFinite(untilMs) ? Math.max(0, untilMs - this.now()) : 2_147_483_647 + const timer = setTimeout(done, Math.min(delayMs, 2_147_483_647)) + timer.unref?.() + this.waiters.add(done) + signal?.addEventListener('abort', done, { once: true }) + }) + } + + private wakeWaiters(): void { + for (const wake of this.waiters) { + wake() } } - private recordObservation( + private record( decision: SpawnAdmissionDecision, charge: SpawnCharge, - facts: SpawnRequestFacts + facts: SpawnRequestFacts, + firstDecision: boolean ): void { - if (decision.kind === 'admit' && charge === 'interactive') { + // Why first only: a woken waiter re-decides, and counting each pass would inflate `waited`. + if ((decision.kind === 'admit' && charge === 'interactive') || !firstDecision) { return } if (decision.kind === 'wait') { - this.wouldWait += 1 + this.waited += 1 } else if (decision.kind === 'defer') { - this.wouldDefer += 1 + this.deferred += 1 } else if (decision.kind === 'refuse_retryable') { - this.wouldRefuse += 1 + this.refused += 1 } try { - this.options.log('spawn-admission-observed', { + this.options.log(this.mode === 'observe' ? 'spawn-admission-observed' : 'spawn-admission', { decision: decision.kind, ...(decision.kind === 'defer' ? { reason: decision.reason } : {}), charge, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index adf1e8a17811..ff7068fecf06 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { ConfirmForegroundProcessRequest, ConfirmShellForegroundRequest, @@ -93,6 +94,8 @@ export type CreateOrAttachRequest = { /** Server-side fence that prevents a client timeout from publishing an orphan PTY. */ cancelAfterMs?: number startupIngress?: PtyStartupIngressIntent + /** Optional; an old daemon ignores it and an old client omits it (legacy pacing). */ + spawnIntent?: SpawnIntent agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts index ba5cd989ad99..d301b715a9ea 100644 --- a/src/main/orcad/orcad-daemon-supervision.test.ts +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -52,7 +52,8 @@ describe('startOrcadDaemon', () => { // daemon must survive its SSH session ending — arming it would kill every terminal the // moment the operator logged out, which is the opposite of the property being bought. expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { - macosLoginSessionWatch: false + macosLoginSessionWatch: false, + spawnAdmission: 'enforce' }) }) @@ -60,6 +61,7 @@ describe('startOrcadDaemon', () => { await startOrcadDaemon('win32') expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { macosLoginSessionWatch: false, + spawnAdmission: 'enforce', startupTimeoutMs: 30_000 }) }) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts index d0fa08cfbe9e..84bd40932df4 100644 --- a/src/main/orcad/orcad-daemon-supervision.ts +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -44,6 +44,7 @@ export async function startOrcadDaemon( // session dies. An orcad daemon must survive its SSH session ending — that is the point. const policy = { macosLoginSessionWatch: false, + spawnAdmission: 'enforce' as const, // A freshly uploaded node.exe plus conpty can take well over 10 s on its first, AV-scanned exec. ...(platform === 'win32' ? { startupTimeoutMs: WINDOWS_DAEMON_STARTUP_TIMEOUT_MS } : {}) } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index a727128b0c10..1f17bc05a44f 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,3 +1,4 @@ +import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentWorkspaceTrustSpawnRequest } from '../../shared/agent-workspace-trust-spawn-request' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' @@ -105,6 +106,8 @@ export type PtySpawnOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' /** Fresh-spawn-only source authority installed before any PTY output is released. */ startupIngress?: PtyStartupIngressIntent + /** Why the caller is starting this terminal; presence lets the host defer an automatic restore. */ + spawnIntent?: SpawnIntent agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index b5cb284db088..3e76bb855de6 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -138,7 +138,9 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: only GUI-spawned macOS daemons watch for login-session death; a headless // serve daemon must survive its spawning session ending (SSH disconnect). await initDaemonPtyProvider(signal, { - macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode + macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode, + // Why: desktop observes for one release; headless hosts pace automatic restores now. + spawnAdmission: state.isServeMode ? 'enforce' : 'observe' }) // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() diff --git a/src/shared/host-spawn-admission.ts b/src/shared/host-spawn-admission.ts index 2b865d160c45..be048504bb23 100644 --- a/src/shared/host-spawn-admission.ts +++ b/src/shared/host-spawn-admission.ts @@ -26,11 +26,43 @@ export type SpawnRequestFacts = { deadlineAt: number } +export const SPAWN_INTENTS: readonly SpawnIntent[] = [ + 'interactive', + 'user-resume', + 'automatic-restore', + 'automation' +] + +export function isSpawnIntent(value: unknown): value is SpawnIntent { + return typeof value === 'string' && SPAWN_INTENTS.some((intent) => intent === value) +} + +export type SpawnDeferralReason = 'paused' | 'live_cap' | 'pressure' | 'deadline' + +/** Sent only to requests that declared an intent; the fork never started. */ +export const TERMINAL_SPAWN_DEFERRED_PREFIX = 'terminal_spawn_deferred:' +/** An undeclared caller at critical pressure; deliberately not a "daemon gone" message, + * which would make the caller replace a healthy daemon. */ +export const TERMINAL_SPAWN_REFUSED_MESSAGE = 'terminal_spawn_refused: host under pressure' + +export function parseTerminalSpawnDeferral(message: string): SpawnDeferralReason | null { + if (!message.startsWith(TERMINAL_SPAWN_DEFERRED_PREFIX)) { + return null + } + const reason = message.slice(TERMINAL_SPAWN_DEFERRED_PREFIX.length) + return reason === 'paused' || + reason === 'live_cap' || + reason === 'pressure' || + reason === 'deadline' + ? reason + : null +} + export type SpawnAdmissionDecision = | { kind: 'admit'; charge: SpawnCharge } /** `retryAt` null means "until a slot is released". */ | { kind: 'wait'; charge: SpawnCharge; retryAt: number | null } - | { kind: 'defer'; reason: 'paused' | 'live_cap' | 'pressure' | 'deadline' } + | { kind: 'defer'; reason: SpawnDeferralReason } | { kind: 'refuse_retryable' } export type SpawnAdmissionLimits = { From 5651141700c51ac67d09a643e1aa439524162205 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:01:32 -0700 Subject: [PATCH 44/58] chore: delete verified-unused modules and exports (#27438) Removes seven modules that only their own tests import, dead orcad exports and protocol constants, spells orcad's configuration exit code once, reuses the shared errorMessage helper, and drops links to the deleted docs/reference folder. Co-authored-by: m4air Co-authored-by: Claude Opus 5.5 --- .github/workflows/computer-e2e.yml | 3 - .github/workflows/ssh-windows-hosts.yml | 1 - config/focus-setting-read-baseline.txt | 1 - config/scripts/build-orcad-prebuilds.mjs | 4 +- config/scripts/ci-shard-timings.json | 6 - src/main/daemon/daemon-host-relocation.ts | 4 +- src/main/daemon/daemon-protocol-version.ts | 3 - src/main/orcad/launcher.ts | 3 +- src/main/orcad/native-host-abi.ts | 2 +- src/main/orcad/node-pty-loader-diagnosis.ts | 8 +- src/main/orcad/node-pty-prebuilt-slot.ts | 2 +- src/main/orcad/node-pty-precondition.ts | 7 +- src/main/orcad/orcad-bundled-runtime.ts | 3 +- src/main/orcad/orcad-configuration-exit.ts | 5 + src/main/orcad/orcad-daemon-supervision.ts | 7 +- src/main/orcad/orcad-exit-code.ts | 4 +- src/main/orcad/orcad-launch-contract.test.ts | 9 +- ...cad-launcher-isolation.integration.test.ts | 1 + src/main/orcad/orcad-local-serve-selection.ts | 11 +- src/main/orcad/orcad-native-preflight.test.ts | 12 +- src/main/orcad/orcad-native-preflight.ts | 10 +- src/main/orcad/orcad-profile-preflight.ts | 3 +- .../ssh/remote-install-coexistence.test.ts | 76 ------ src/main/ssh/remote-install-coexistence.ts | 83 ------ .../ssh-previous-relay-census-test-double.ts | 11 - src/main/ssh/ssh-remote-cli-launcher.test.ts | 249 ------------------ src/main/ssh/ssh-remote-cli-launcher.ts | 247 ----------------- .../ssh/ssh-session-command-audit.test.ts | 20 -- src/main/ssh/ssh-session-command-audit.ts | 44 ---- .../shell-ready-marker-scan.test.ts | 39 --- .../terminal-pane/shell-ready-marker-scan.ts | 54 ---- .../terminal-drop-runtime-owner.test.ts | 49 ---- .../terminal-drop-runtime-owner.ts | 32 --- src/shared/issue-link-input.test.ts | 118 --------- src/shared/issue-link-input.ts | 71 ----- tests/e2e/orcad-serve-mode-switch.spec.ts | 2 +- 36 files changed, 42 insertions(+), 1162 deletions(-) create mode 100644 src/main/orcad/orcad-configuration-exit.ts delete mode 100644 src/main/ssh/remote-install-coexistence.test.ts delete mode 100644 src/main/ssh/remote-install-coexistence.ts delete mode 100644 src/main/ssh/ssh-previous-relay-census-test-double.ts delete mode 100644 src/main/ssh/ssh-remote-cli-launcher.test.ts delete mode 100644 src/main/ssh/ssh-remote-cli-launcher.ts delete mode 100644 src/main/ssh/ssh-session-command-audit.test.ts delete mode 100644 src/main/ssh/ssh-session-command-audit.ts delete mode 100644 src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts delete mode 100644 src/shared/issue-link-input.test.ts delete mode 100644 src/shared/issue-link-input.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 49f03af29795..cb6a63e04159 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -43,8 +43,6 @@ on: - 'src/main/runtime/rpc/dispatcher.ts' - 'src/main/runtime/rpc/errors.ts' - 'src/main/runtime/rpc/methods/computer*.ts' - - 'src/main/ssh/ssh-remote-cli-launcher.ts' - - 'src/main/ssh/ssh-remote-cli-launcher.test.ts' - 'src/shared/computer-use-*.ts' - 'tests/e2e/computer-linux.e2e.ts' - 'tests/e2e/computer-windows.e2e.ts' @@ -85,7 +83,6 @@ jobs: # on hosted Windows. Use the shared config so this job matches pnpm test. - run: >- pnpm vitest run --config config/vitest.config.ts - src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs config/scripts/macos-computer-helper-owner-loss-processes.test.mjs config/scripts/computer-use-smoke.test.mjs diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 9da215290278..6973308fa360 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -15,7 +15,6 @@ on: - 'src/main/ssh/ssh-remote-*' - 'src/main/ssh/ssh-hostile-host-*' - 'src/main/ssh/ssh-windows-host-*' - - 'src/main/ssh/ssh-session-command-audit.ts' - 'src/wsl-guest/**' - 'src/packages/process-host/**' - 'pnpm-workspace.yaml' diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 2054b133abef..bd919fb83fec 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -233,7 +233,6 @@ 1 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts 1 src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts -2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts 1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts 2 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts 1 src/renderer/src/components/terminal-pane/terminal-keyboard-hook.ts diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs index 4145edf1b2d0..b2febb748c21 100644 --- a/config/scripts/build-orcad-prebuilds.mjs +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -135,8 +135,8 @@ export function assertNodePtyPatchApplied(nodePtyDir) { [ '[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.', ...missing.map((line) => ` - ${line}`), - 'A prebuilt compiled without it will not load on Ubuntu 20.04 (see', - 'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.' + 'A prebuilt compiled without it will not load on Ubuntu 20.04 (#9902).', + 'Run `pnpm install` to apply patches.' ].join('\n') ) } diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 769beffd4401..57f6977cfa8c 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5433,7 +5433,6 @@ "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 107, "src/main/ssh/relay-socket-path-limit.test.ts": 559, "src/main/ssh/relay-version-dir-liveness.test.ts": 577, - "src/main/ssh/remote-install-coexistence.test.ts": 51, "src/main/ssh/remote-install-gc-termination.test.ts": 267, "src/main/ssh/remote-install-model.test.ts": 199, "src/main/ssh/remote-install-previous-version.test.ts": 187, @@ -5562,7 +5561,6 @@ "src/main/ssh/ssh-relay-versioned-install-termination.test.ts": 239, "src/main/ssh/ssh-relay-versioned-install.test.ts": 327, "src/main/ssh/ssh-relay-windows-launch-command.test.ts": 151, - "src/main/ssh/ssh-remote-cli-launcher.test.ts": 145, "src/main/ssh/ssh-remote-commands.test.ts": 7310, "src/main/ssh/ssh-remote-node-resolution.test.ts": 318, "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 173, @@ -5574,7 +5572,6 @@ "src/main/ssh/ssh-request-outcome-verdict.test.ts": 52, "src/main/ssh/ssh-response-pending-frames.test.ts": 96, "src/main/ssh/ssh-security-key-identity.test.ts": 156, - "src/main/ssh/ssh-session-command-audit.test.ts": 126, "src/main/ssh/ssh-session-limit-error.test.ts": 49, "src/main/ssh/ssh-system-fallback.test.ts": 307, "src/main/ssh/ssh-system-transport.integration.test.ts": 1301, @@ -8717,7 +8714,6 @@ "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 50, "src/renderer/src/components/terminal-pane/scan22-ssh-new-tab-disposed-spawn-kill.repro.test.ts": 412, "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 270, - "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 52, "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 50, "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 60, "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 58, @@ -8765,7 +8761,6 @@ "src/renderer/src/components/terminal-pane/terminal-drop-pane-resolution.test.ts": 141, "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 120, "src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts": 679, - "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 326, "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 60, "src/renderer/src/components/terminal-pane/terminal-drop-source-owner.test.ts": 509, "src/renderer/src/components/terminal-pane/terminal-drop-transport-owner.test.ts": 338, @@ -11055,7 +11050,6 @@ "src/shared/image-paste-following-text.test.ts": 47, "src/shared/import-copy-name.test.ts": 56, "src/shared/in-flight-promise-dedupe.test.ts": 80, - "src/shared/issue-link-input.test.ts": 65, "src/shared/jcode-runtime-dir.test.ts": 54, "src/shared/jcode-session-files.test.ts": 58, "src/shared/jcode-terminal-title.test.ts": 50, diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 6d0353668a18..8acd40df7b4b 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -35,8 +35,8 @@ import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-pr * any failure returns null and the caller forks the install-dir host (pre-relocation behavior). * * What escapes the updater is the PATH, not the file name: electron-builder's kill sweep selects - * processes whose image path sits under $INSTDIR. See docs/reference/windows-daemon-host-relocation.md - * for the survival contract and why the exe is copied verbatim rather than renamed. + * processes whose image path sits under $INSTDIR, which is why the exe is copied verbatim rather + * than renamed. */ export type RelocatedDaemonHost = { diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 2504b90232b7..2cede1068df7 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -11,10 +11,7 @@ export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 -export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36 export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35 -export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34 -export const WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION = 33 export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32 export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 diff --git a/src/main/orcad/launcher.ts b/src/main/orcad/launcher.ts index 4e43729084a6..747f30cc7847 100644 --- a/src/main/orcad/launcher.ts +++ b/src/main/orcad/launcher.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { ORCAD_SERVER_ENTRY_FILENAME } from '../../shared/orcad-artifacts' import { assertOrcadServerRuntime, handoffToBundledOrcad } from './orcad-bundled-runtime' +import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' declare const ORCAD_SERVER_SHA256: string @@ -21,7 +22,7 @@ try { } } catch (error) { console.error('orcad: failed to launch:', error) - process.exit(78) + process.exit(ORCAD_EXIT_CONFIGURATION) } loadServer?.() diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts index 532409104bd4..a65d8f371736 100644 --- a/src/main/orcad/native-host-abi.ts +++ b/src/main/orcad/native-host-abi.ts @@ -25,7 +25,7 @@ export type NativeHostAbi = { nodeAbi: string } -/** Stock Ubuntu 20.04. See docs/reference/linux-glibc-compatibility.md. */ +/** Stock Ubuntu 20.04. */ export const GLIBC_FLOOR = '2.31' type ReportHeader = { glibcVersionRuntime?: unknown } diff --git a/src/main/orcad/node-pty-loader-diagnosis.ts b/src/main/orcad/node-pty-loader-diagnosis.ts index 8024a8e0ecb9..4798a817a4ca 100644 --- a/src/main/orcad/node-pty-loader-diagnosis.ts +++ b/src/main/orcad/node-pty-loader-diagnosis.ts @@ -28,12 +28,10 @@ export type NodePtyLoadCause = { * miss — `Cannot find module` — even when the real failure was the dynamic loader * refusing `build/Release/pty.node`. Anything acting on that tail sends the operator to * install a module that is already installed. + * + * Returns the real cause a flattened message still carries, when the last attempt was the + * telling one. */ -export function isFlattenedNodePtyLoaderMessage(message: string): boolean { - return /Failed to load native module: (?:conpty|pty)\.node(?:,|:|$)/.test(message) -} - -/** The real cause a flattened message still carries, when the last attempt was the telling one. */ export function classifyNodePtyLoaderMessage(message: string): NodePtyLoadCause { const abiMismatch = parseNodeAbiMismatch(message) if (abiMismatch) { diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts index 3842ebd589cf..1917f8d276f2 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -11,7 +11,7 @@ * The prebuilds themselves are built from the PATCHED source (config/patches/node-pty@1.1.0.patch) * by config/scripts/build-orcad-prebuilds.mjs. An upstream tarball would not do: the patch * carries the `.symver` pins and the `--no-as-needed` libutil/libpthread flags that hold the - * glibc 2.28 server-slot floor (docs/reference/linux-glibc-compatibility.md). + * glibc 2.28 server-slot floor. * * The gate is N-API + libc + arch, not NODE_MODULE_VERSION: the slots are Node-API addons, * so one build loads on the pinned Node and on any host Node whose N-API level is at least diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts index 47e25b6deb3f..9c62bcf0c936 100644 --- a/src/main/orcad/node-pty-precondition.ts +++ b/src/main/orcad/node-pty-precondition.ts @@ -5,8 +5,7 @@ * module throws `MODULE_NOT_FOUND` and a caller can degrade. A module that is present but * built against the wrong libc or Node ABI is refused by the dynamic loader, and in the * worst case takes the process down before any handler exists — that is #9902, which - * crashed the desktop app on Ubuntu 20.04 before a window appeared - * (docs/reference/linux-glibc-compatibility.md). + * crashed the desktop app on Ubuntu 20.04 before a window appeared. * * So the load happens in a CHILD process. Whatever the child does — throw, abort, die on * a signal — is data to us rather than our own death, and the operator gets a sentence @@ -47,8 +46,8 @@ const PROBE_TIMEOUT_MS = 20_000 * * Why `unverifiable` is separate from `blocked`: a probe that times out or cannot spawn * says nothing about node-pty, and refusing to boot on it would brick working hosts for - * a reason that was never established. Same verdict discipline as - * docs/reference/ssh-execution-boundary.md — loss of contact is not proof of death. + * a reason that was never established. Same verdict discipline as the SSH execution + * boundary: loss of contact is not proof of death. */ export type NodePtyPreconditionStatus = 'ok' | 'degraded' | 'blocked' | 'unverifiable' diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index c6d1f58df754..297b8f7c48ed 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path' import { constants } from 'node:os' import { spawnProcess } from '@orca/process-host' import { resolveOrcadInstallRoot } from './orcad-app-paths' +import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' import { ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_SERVER_TARGET_FILENAME, @@ -136,7 +137,7 @@ export function handoffToBundledOrcad(): boolean { child.once('error', (error) => { cleanup() console.error('orcad: could not start the bundled runtime:', error.message) - process.exit(78) + process.exit(ORCAD_EXIT_CONFIGURATION) }) child.once('exit', (code, signal) => { cleanup() diff --git a/src/main/orcad/orcad-configuration-exit.ts b/src/main/orcad/orcad-configuration-exit.ts new file mode 100644 index 000000000000..6185aa446808 --- /dev/null +++ b/src/main/orcad/orcad-configuration-exit.ts @@ -0,0 +1,5 @@ +/** + * EX_CONFIG, also systemd's RestartPreventExitStatus: the host is not equipped, so a supervisor + * restart cannot fix it. Dependency-free so the launcher can exit with it before anything loads. + */ +export const ORCAD_EXIT_CONFIGURATION = 78 diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts index 84bd40932df4..79ec4f3ed281 100644 --- a/src/main/orcad/orcad-daemon-supervision.ts +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -2,7 +2,7 @@ * orcad's half of the supervision contract: it spawns and supervises the terminal daemon. * * The whole reason the peer model is recommended over an SSH target is that daemon-backed - * PTYs stay `live` across a runtime restart (docs/reference/ssh-execution-boundary.md). + * PTYs stay `live` across a runtime restart. * Without a daemon here, every orcad restart, update and rollback is a SIGKILL for every * running terminal — on the host whose selling point is that work survives the client going * away. @@ -17,13 +17,10 @@ import { initDaemonPtyProvider, readDaemonPidRecord } from '../daemon/daemon-init' +import { errorMessage } from '../../shared/error-message' const WINDOWS_DAEMON_STARTUP_TIMEOUT_MS = 30_000 -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - export type OrcadDaemonStartup = | { state: 'live'; pid: number | null } | { state: 'degraded'; reason: string } diff --git a/src/main/orcad/orcad-exit-code.ts b/src/main/orcad/orcad-exit-code.ts index d3ebaa143036..6ae468fe9367 100644 --- a/src/main/orcad/orcad-exit-code.ts +++ b/src/main/orcad/orcad-exit-code.ts @@ -2,11 +2,9 @@ import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' import { OrcadInstanceLockError } from './orcad-instance-lock' import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' +import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' -export const ORCAD_EXIT_OK = 0 export const ORCAD_EXIT_FAILED = 1 -/** systemd RestartPreventExitStatus code: a configuration fault restarting cannot fix. */ -export const ORCAD_EXIT_CONFIGURATION = 78 /** Configuration faults cannot be repaired by a supervisor restart. */ export function resolveOrcadExitCode(error: unknown): number { diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index 956a511cccb1..93937ef49b73 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -1,17 +1,14 @@ /** * The two things a supervisor reads off a launch: what the arguments mean, and what an exit - * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. + * code means. Both are part of the supervisor ops contract. */ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { parseArgs } from './orcad-entry' -import { - ORCAD_EXIT_CONFIGURATION, - ORCAD_EXIT_FAILED, - resolveOrcadExitCode -} from './orcad-exit-code' +import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' +import { ORCAD_EXIT_FAILED, resolveOrcadExitCode } from './orcad-exit-code' import { startOrcadWithLifecycle } from './orcad-lifecycle' import { beginAgentSessionRuntimeRecord, diff --git a/src/main/orcad/orcad-launcher-isolation.integration.test.ts b/src/main/orcad/orcad-launcher-isolation.integration.test.ts index 361a3de7a16a..d0d7b2cd283c 100644 --- a/src/main/orcad/orcad-launcher-isolation.integration.test.ts +++ b/src/main/orcad/orcad-launcher-isolation.integration.test.ts @@ -70,6 +70,7 @@ afterAll(async () => { it('keeps the server and profile-state graph outside the compatibility launcher', async () => { expect(inputs.filter((path) => path.startsWith('src/main/'))).toEqual([ 'src/main/orcad/orcad-app-paths.ts', + 'src/main/orcad/orcad-configuration-exit.ts', 'src/main/orcad/orcad-bundled-runtime.ts', 'src/main/orcad/launcher.ts' ]) diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index bbc0bb54f3f5..79b81c46bac0 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -32,6 +32,7 @@ import { detectNativeHostAbi, nativeSlotName } from './native-host-abi' import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer' import { materializeCachedNodeRuntime } from '../ssh/pinned-runtime-materializer' import { serveProfileHasSshTargets } from './serve-profile-ssh-targets' +import { errorMessage } from '../../shared/error-message' const NATIVE_PREFLIGHT_TIMEOUT_MS = 30_000 @@ -88,13 +89,13 @@ export async function selectServeRuntime( }) await placeSlotRuntime(slotDir, target, cacheRoot, input.materializeRuntime) } catch (error) { - return electron(`the orcad slot for ${target} could not be prepared: ${errorText(error)}`) + return electron(`the orcad slot for ${target} could not be prepared: ${errorMessage(error)}`) } let runtime: string | null try { runtime = resolveBundledOrcadRuntime(slotDir) } catch (error) { - return electron(`the orcad slot is incomplete: ${errorText(error)}`) + return electron(`the orcad slot is incomplete: ${errorMessage(error)}`) } if (!runtime) { return electron('the orcad slot names no pinned runtime') @@ -128,7 +129,7 @@ function profileSshReason(input: ServeRuntimeSelectionInput): string | null { } catch (error) { // Why: fail closed. The read-only probe rejects profiles the serve host would still migrate // (an older schema), and guessing "no SSH" there leaves saved targets unreachable. - return `could not tell whether this profile has SSH targets, which orcad cannot serve yet (${errorText(error)})` + return `could not tell whether this profile has SSH targets, which orcad cannot serve yet (${errorMessage(error)})` } } @@ -164,7 +165,3 @@ async function runNativePreflight(runtime: string, entry: string): Promise { verdict({ status: 'blocked', reason: 'libc_floor', detail: 'the binary requires GLIBC_2.34' }) ) - expect(exit).toHaveBeenCalledWith(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + expect(exit).toHaveBeenCalledWith(ORCAD_EXIT_CONFIGURATION) expect(warn).not.toHaveBeenCalled() const message = fail.mock.calls[0][0] as string expect(message).toContain('newer C library') @@ -61,8 +59,8 @@ describe('runOrcadNativePreflight', () => { }) it('exits with EX_CONFIG so a supervisor does not restart an unequippable host forever', () => { - expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).toBe(78) - expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).not.toBe(1) + expect(ORCAD_EXIT_CONFIGURATION).toBe(78) + expect(ORCAD_EXIT_CONFIGURATION).not.toBe(1) }) it('publishes the blocked cause as a status degradation', () => { diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts index 0f3c2cf89702..546353dd9e35 100644 --- a/src/main/orcad/orcad-native-preflight.ts +++ b/src/main/orcad/orcad-native-preflight.ts @@ -13,13 +13,7 @@ import { probeLocalBuildToolchainHints, type NodePtyPreconditionVerdict } from './node-pty-precondition' - -/** - * EX_CONFIG. Why not 1: a supervisor that restarts on 1 would restart forever against a - * host that can never load this binary. This code says "the host is not equipped", which - * is a different instruction from "it crashed". - */ -export const ORCAD_NATIVE_PRECONDITION_EXIT_CODE = 78 +import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' export type NativePreflightHooks = { check?: () => NodePtyPreconditionVerdict @@ -60,7 +54,7 @@ export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boole if (verdict.status === 'blocked') { const hints = (hooks.toolchainHints ?? probeLocalBuildToolchainHints)(verdict.abi.platform) fail(`orcad: ${formatNodePtyPreconditionReport(verdict, message, hints)}`) - exit(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + exit(ORCAD_EXIT_CONFIGURATION) return false } diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index f4871f125d68..4a3be34b44ba 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -11,6 +11,7 @@ import { type OrcadProfilePreflightResponse } from '../../shared/orcad-profile-preflight' import { readOrcadArtifactIdentity } from './orcad-artifact-identity' +import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' import { ORCAD_SERVER_ENTRY_FILENAME, ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' import { runProcess } from '@orca/process-host' @@ -39,7 +40,7 @@ export async function preflightBundledOrcadStartup(): Promise { terminationBarrier: true }) if (result.code !== 0 || result.timedOut || result.outputTruncated) { - const Failure = result.code === 78 ? OrcadBundledRuntimeError : Error + const Failure = result.code === ORCAD_EXIT_CONFIGURATION ? OrcadBundledRuntimeError : Error throw new Failure(`The bundled Orca runtime failed readiness: ${result.stderr}`) } try { diff --git a/src/main/ssh/remote-install-coexistence.test.ts b/src/main/ssh/remote-install-coexistence.test.ts deleted file mode 100644 index ce844587d62b..000000000000 --- a/src/main/ssh/remote-install-coexistence.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { selectRemoteInstallModel } from './remote-install-coexistence' - -const BOTH_INSTALLED = ['relay-0.1.0+aa01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01'] - -describe('what a client does when it finds both models installed', () => { - it('uses the registered model and leaves the other install alone', () => { - const selection = selectRemoteInstallModel({ - registration: 'orcad-peer', - installedDirNames: BOTH_INSTALLED - }) - expect(selection).toMatchObject({ outcome: 'use', model: 'orcad' }) - expect(selection.outcome === 'use' && selection.coexisting).toEqual(['relay-0.1.0+aa01']) - expect(selection.outcome === 'use' && selection.note).toContain( - 'garbage-collects only its own namespace' - ) - }) - - it('does not switch model just because the other one is also on disk', () => { - const selection = selectRemoteInstallModel({ - registration: 'ssh-target', - installedDirNames: BOTH_INSTALLED - }) - expect(selection).toMatchObject({ outcome: 'use', model: 'relay' }) - expect(selection.outcome === 'use' && selection.coexisting).toEqual([ - 'orcad-0.2.0+bb01', - 'orcad-0.1.0+aa01' - ]) - }) - - it('picks the registered model even when only the other one is installed', () => { - // On-disk presence is diagnostic, never a vote: an orcad-registered host with only relay - // dirs is a first orcad deploy, not a reason to fall back to the relay. - const selection = selectRemoteInstallModel({ - registration: 'orcad-peer', - installedDirNames: ['relay-0.1.0+aa01'] - }) - expect(selection).toMatchObject({ outcome: 'use', model: 'orcad' }) - }) - - it('refuses a machine registered under both models', () => { - const selection = selectRemoteInstallModel({ - registration: 'both', - installedDirNames: BOTH_INSTALLED - }) - expect(selection).toMatchObject({ - outcome: 'refuse', - code: 'remote_host_registered_under_both_models' - }) - // The forbidden thing is two registrations, not two directories — say so, or a user will - // "fix" it by deleting an install that is serving someone. - expect(selection.outcome === 'refuse' && selection.reason).toContain( - 'Leaving both install directories on disk is fine' - ) - }) - - it('refuses to infer a model for an unregistered machine', () => { - const selection = selectRemoteInstallModel({ - registration: 'none', - installedDirNames: BOTH_INSTALLED - }) - expect(selection).toMatchObject({ - outcome: 'refuse', - code: 'remote_host_not_registered' - }) - }) - - it('says nothing when there is nothing coexisting', () => { - const selection = selectRemoteInstallModel({ - registration: 'orcad-peer', - installedDirNames: ['orcad-0.2.0+bb01'] - }) - expect(selection).toMatchObject({ outcome: 'use', model: 'orcad', note: null }) - }) -}) diff --git a/src/main/ssh/remote-install-coexistence.ts b/src/main/ssh/remote-install-coexistence.ts deleted file mode 100644 index a24ac36cfb1f..000000000000 --- a/src/main/ssh/remote-install-coexistence.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * What a client does when it finds both a relay and an orcad installed on one host. - * - * `docs/design/shipping-orcad.html` §06 draws the line the boundary doc actually draws: - * two *directories* on disk are fine and permanent; two *registered targets* for one - * machine are forbidden, because that is what splits a machine's worktrees across two - * identities (`docs/reference/ssh-execution-boundary.md`). - * - * So the model is never inferred from the filesystem. It is decided by how the user - * registered the host, and the on-disk inventory is only ever diagnostic. Inferring it — - * "an orcad dir exists, so prefer orcad" — would let a GC pass, a half-finished install or - * a stale tree silently re-point a live connection at a different execution identity. - */ -import { inventoryRemoteInstallDirs, type RemoteInstallModelId } from './remote-install-model' - -/** - * How this host is registered in the client's own records. - * - * `both` is representable on purpose: it is a state a user can reach by adding an SSH - * target for a machine they have already paired, and the point of this module is to refuse - * it loudly instead of picking one. - */ -export type RemoteHostRegistration = 'ssh-target' | 'orcad-peer' | 'both' | 'none' - -export type RemoteInstallSelection = - | { - outcome: 'use' - model: RemoteInstallModelId - /** Other-model dirs present on this host. Left alone; never GC'd by the chosen model. */ - coexisting: string[] - /** Non-null when there is something an operator should know but nothing to refuse over. */ - note: string | null - } - | { - outcome: 'refuse' - code: 'remote_host_registered_under_both_models' | 'remote_host_not_registered' - reason: string - } - -/** - * Choose the execution model for a connection to a host, given its registration and - * whatever happens to be installed there. - */ -export function selectRemoteInstallModel(input: { - registration: RemoteHostRegistration - /** Raw directory names under `~/.orca-remote/`, as listed on the host. */ - installedDirNames: readonly string[] -}): RemoteInstallSelection { - if (input.registration === 'both') { - return { - outcome: 'refuse', - code: 'remote_host_registered_under_both_models', - reason: - 'This machine is registered both as an SSH target and as a paired orcad peer. One ' + - 'machine must have one execution identity, or its worktrees and terminals split ' + - 'across two owners that cannot see each other. Remove one registration. Leaving ' + - 'both install directories on disk is fine and expected.' - } - } - if (input.registration === 'none') { - return { - outcome: 'refuse', - code: 'remote_host_not_registered', - reason: - 'This machine has no execution model registered. Add it as an SSH target or pair it ' + - 'as an orcad peer; what is already installed on it does not decide which it is.' - } - } - const model: RemoteInstallModelId = input.registration === 'ssh-target' ? 'relay' : 'orcad' - const inventory = inventoryRemoteInstallDirs(input.installedDirNames) - const coexisting = model === 'relay' ? inventory.orcad : inventory.relay - return { - outcome: 'use', - model, - coexisting, - note: - coexisting.length > 0 - ? `This host also has ${coexisting.length} ${model === 'relay' ? 'orcad' : 'relay'} ` + - `install directory(ies) (${coexisting.join(', ')}). They are left untouched: each ` + - 'model garbage-collects only its own namespace.' - : null - } -} diff --git a/src/main/ssh/ssh-previous-relay-census-test-double.ts b/src/main/ssh/ssh-previous-relay-census-test-double.ts deleted file mode 100644 index 8626e1d0c8ef..000000000000 --- a/src/main/ssh/ssh-previous-relay-census-test-double.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * An inert stand-in for `ssh-previous-relay-terminals`, for session tests whose deploy mocks carry - * no host details: no older relay is ever found, so reattach keeps its ordinary not-found path. - */ -const noCensus = { endpoints: [], status: 'complete' as const, bridgeable: false } - -export const startPreviousRelayCensus = async (): Promise => noCensus -export const previousRelayCensus = async (): Promise => noCensus -export const clearPreviousRelayCensus = (): void => {} -export const isReattachHeldByPreviousRelay = async (): Promise => false -export const previousRelayMayHoldTerminals = async (): Promise => false diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts deleted file mode 100644 index 5d4419ee2edb..000000000000 --- a/src/main/ssh/ssh-remote-cli-launcher.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Buffer } from 'node:buffer' -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -// Why: the compile case is six process creations - powershell.exe -> csc.exe, -// then the freshly built orca.exe -> node.exe, twice - and hosted Windows -// runners periodically slow process creation down. Across 176 native-smoke runs -// it spanned 1.9s-35.4s (p50 4.3s) while this file's powershell-only test held -// its median, so the cost is the runner, not the assertions. The shared 30s -// testTimeout still leaves 1.1% of those runs red; 60s clears all 176. The old -// 15s was written when this job ran bare vitest on the 5s default, before -// #8909 pointed it at config/vitest.config.ts. -const WINDOWS_LAUNCHER_TIMEOUT_MS = 60_000 - -function itWindows(name: string, test: () => void): void { - const runner = process.platform === 'win32' ? it : it.skip - runner(name, { timeout: WINDOWS_LAUNCHER_TIMEOUT_MS }, test) -} - -function decodePowerShellCommand(command: string): string { - const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] - if (!encoded) { - throw new Error(`Expected an encoded PowerShell command: ${command}`) - } - return Buffer.from(encoded, 'base64').toString('utf16le') -} - -describe('SSH remote Orca CLI launcher', () => { - function windowsInstallPlan(): ReturnType { - return createRemoteCliInstallPlan({ - binDir: 'C:/Users/me user/.orca-relay/bin', - relayDir: 'C:/Users/me user/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123', - credentialFile: 'C:/Users/me user/.orca-remote/relay-v1/relay.sock.credential', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - } - - it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { - const plan = windowsInstallPlan() - - expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') - expect(plan.files).toHaveLength(1) - expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') - expect(plan.files[0]?.contents).toContain('ProcessStartInfo') - expect(plan.files[0]?.contents).toContain('"--orca-cli"') - expect(plan.files[0]?.contents).toContain('socketPath + ".credential"') - expect(plan.files[0]?.contents).toContain("value[index] == '\"'") - expect(plan.files[0]?.contents).toContain("character == '\\\\'") - expect(plan.files[0]?.contents).not.toContain('cmd.exe') - expect(plan.files[0]?.contents).not.toContain('%*') - - expect(plan.postWriteCommands).toHaveLength(1) - const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '') - expect(compileScript).toContain('v4.0.30319\\csc.exe') - // Why: legacy csc.exe is invoked from the bin directory with bare, space-free - // file names so PowerShell 5.1 never mangles a space-bearing absolute path. - expect(compileScript).toContain( - "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'" - ) - expect(compileScript).toContain('/out:orca.exe') - expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') - expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') - }) - - it('removes the legacy orca.cmd only after every compile guard has passed', () => { - const script = decodePowerShellCommand(windowsInstallPlan().postWriteCommands[0] ?? '') - const legacyShimRemoval = - "Remove-Item -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.cmd' -Force -ErrorAction SilentlyContinue" - // Why: a host missing csc.exe or failing the compile must keep its existing - // CLI, so every fail-closed guard precedes the legacy %* shim removal. - const guards = [ - "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", - 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', - "if (-not (Test-Path -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.exe' -PathType Leaf))" - ] - expect(script).toContain(legacyShimRemoval) - for (const guard of guards) { - expect(script).toContain(guard) - expect(script.indexOf(guard)).toBeLessThan(script.indexOf(legacyShimRemoval)) - } - }) - - itWindows('preserves a multiline argument through the compiled remote launcher', () => { - const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) - try { - const binDir = join(root, 'bin').replaceAll('\\', '/') - const relayDir = join(root, 'relay').replaceAll('\\', '/') - const sockPath = '\\\\.\\pipe\\orca-relay-test' - const credentialFile = `${relayDir}/relay.sock.credential` - const plan = createRemoteCliInstallPlan({ - binDir, - relayDir, - nodePath: process.execPath, - sockPath, - credentialFile, - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - for (const file of plan.files) { - mkdirSync(dirname(file.path), { recursive: true }) - writeFileSync(file.path, file.contents, 'utf8') - } - - const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeTruthy() - const compile = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-EncodedCommand', - encoded! - ], - { encoding: 'utf8' } - ) - expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0) - - mkdirSync(relayDir, { recursive: true }) - writeFileSync( - join(relayDir, 'relay.js'), - 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', - 'utf8' - ) - const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' - const launched = spawnSync( - plan.launcherPath, - ['orchestration', 'send', '--body', body, '--json'], - { - encoding: 'utf8', - env: { - ...process.env, - ORCA_RELAY_NODE_PATH: process.execPath, - ORCA_RELAY_DIR: relayDir, - ORCA_RELAY_SOCKET_PATH: sockPath, - ORCA_RELAY_CREDENTIAL_FILE: credentialFile - } - } - ) - - expect(launched.status, launched.stderr).toBe(0) - expect(JSON.parse(launched.stdout)).toEqual([ - '--sock-path', - sockPath, - '--credential-file', - credentialFile, - '--orca-cli', - 'orchestration', - 'send', - '--body', - body, - '--json' - ]) - - const defaulted = spawnSync(plan.launcherPath, ['status'], { - encoding: 'utf8', - env: { - ...process.env, - ORCA_RELAY_NODE_PATH: process.execPath, - ORCA_RELAY_DIR: relayDir, - ORCA_RELAY_SOCKET_PATH: sockPath, - ORCA_RELAY_CREDENTIAL_FILE: '' - } - }) - expect(defaulted.status, defaulted.stderr).toBe(0) - expect(JSON.parse(defaulted.stdout)).toEqual([ - '--sock-path', - sockPath, - '--credential-file', - `${sockPath}.credential`, - '--orca-cli', - 'status' - ]) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - itWindows('preserves the existing orca.cmd when the compiler is missing', () => { - const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) - try { - const binDir = join(root, 'bin').replaceAll('\\', '/') - mkdirSync(binDir, { recursive: true }) - const legacyShimPath = join(binDir, 'orca.cmd') - writeFileSync(legacyShimPath, '@echo legacy orca cli\r\n', 'utf8') - - const plan = createRemoteCliInstallPlan({ - binDir, - relayDir: join(root, 'relay').replaceAll('\\', '/'), - nodePath: process.execPath, - sockPath: '\\\\.\\pipe\\orca-relay-test', - credentialFile: join(root, 'relay', 'relay.sock.credential').replaceAll('\\', '/'), - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - for (const file of plan.files) { - mkdirSync(dirname(file.path), { recursive: true }) - writeFileSync(file.path, file.contents, 'utf8') - } - - const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeTruthy() - // Point WINDIR at a directory with no csc.exe so compiler discovery fails. - const compile = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-EncodedCommand', - encoded! - ], - { encoding: 'utf8', env: { ...process.env, WINDIR: root, SystemRoot: root } } - ) - - expect(compile.status).not.toBe(0) - expect(existsSync(legacyShimPath), 'existing orca.cmd must survive a failed install').toBe( - true - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - it('keeps the POSIX launcher as an argv-preserving shell exec', () => { - const plan = createRemoteCliInstallPlan({ - binDir: '/home/me/.orca-relay/bin', - relayDir: '/home/me/.orca-remote/relay-v1', - nodePath: '/usr/bin/node', - sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca') - expect(plan.files).toEqual([ - expect.objectContaining({ - path: '/home/me/.orca-relay/bin/orca', - contents: expect.stringContaining('--orca-cli "$@"') - }) - ]) - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts deleted file mode 100644 index aaa3d09b6ce2..000000000000 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' - -type RemoteCliInstallEnv = { - binDir: string - relayDir: string - nodePath: string - sockPath: string - credentialFile?: string - hostPlatform: RemoteHostPlatform -} - -type RemoteCliInstallFile = { - path: string - contents: string -} - -export type RemoteCliInstallPlan = { - launcherPath: string - files: RemoteCliInstallFile[] - postWriteCommands: string[] -} - -const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; -using System.Diagnostics; -using System.IO; -using System.Text; - -internal static class OrcaRemoteCliLauncher -{ - private static int Main(string[] args) - { - try - { - string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); - string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); - string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); - string credentialFile = Environment.GetEnvironmentVariable("ORCA_RELAY_CREDENTIAL_FILE"); - if (String.IsNullOrEmpty(credentialFile)) - { - credentialFile = socketPath + ".credential"; - } - string relayPath = Path.Combine(relayDirectory, "relay.js"); - - if (!File.Exists(nodePath)) - { - Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); - return 1; - } - if (!File.Exists(relayPath)) - { - Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); - return 1; - } - - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = nodePath, - Arguments = BuildArguments(relayPath, socketPath, credentialFile, args), - UseShellExecute = false - }; - - using (Process child = Process.Start(startInfo)) - { - child.WaitForExit(); - return child.ExitCode; - } - } - catch (Exception error) - { - Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); - return 1; - } - } - - private static string RequireEnvironmentVariable(string name) - { - string value = Environment.GetEnvironmentVariable(name); - if (String.IsNullOrEmpty(value)) - { - throw new InvalidOperationException(name + " is not set."); - } - return value; - } - - private static string BuildArguments(string relayPath, string socketPath, string credentialFile, string[] args) - { - StringBuilder commandLine = new StringBuilder(); - AppendArgument(commandLine, relayPath); - AppendArgument(commandLine, "--sock-path"); - AppendArgument(commandLine, socketPath); - AppendArgument(commandLine, "--credential-file"); - AppendArgument(commandLine, credentialFile); - AppendArgument(commandLine, "--orca-cli"); - foreach (string arg in args) - { - AppendArgument(commandLine, arg); - } - return commandLine.ToString(); - } - - private static void AppendArgument(StringBuilder commandLine, string value) - { - if (commandLine.Length > 0) - { - commandLine.Append(' '); - } - commandLine.Append(QuoteArgument(value)); - } - - private static string QuoteArgument(string value) - { - bool requiresQuotes = value.Length == 0; - for (int index = 0; index < value.Length && !requiresQuotes; index += 1) - { - requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); - } - if (!requiresQuotes) - { - return value; - } - - StringBuilder quoted = new StringBuilder("\""); - int backslashCount = 0; - foreach (char character in value) - { - if (character == '\\') - { - backslashCount += 1; - continue; - } - if (character == '"') - { - quoted.Append('\\', backslashCount * 2 + 1); - quoted.Append('"'); - } - else - { - quoted.Append('\\', backslashCount); - quoted.Append(character); - } - backslashCount = 0; - } - - quoted.Append('\\', backslashCount * 2); - quoted.Append('"'); - return quoted.ToString(); - } -} -` - -function quoteSh(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function createWindowsLauncherCompileCommand( - binDir: string, - sourceFileName: string, - launcherFileName: string, - launcherPath: string, - sourcePath: string, - legacyShimPath: string -): string { - // Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by - // Windows PowerShell 5.1's native-argument quoting, so compile from the bin - // directory and pass only the bare, space-free launcher file names. - const compilerArgs = [ - '/nologo', - '/target:exe', - '/optimize+', - '/warnaserror+', - `/out:${launcherFileName}`, - sourceFileName - ] - .map(powerShellNativeArg) - .join(' ') - return powerShellCommand( - [ - `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, - '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', - `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, - '$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1', - "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", - `& $compiler ${compilerArgs}`, - 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', - `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, - // Why: remove the legacy %* bridge only after a successful compile, so a - // host missing csc.exe keeps its existing CLI (orca.exe shadows orca.cmd). - `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, - `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` - ].join('; ') - ) -} - -export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan { - if (isWindowsRemoteHost(env.hostPlatform)) { - const launcherFileName = 'orca.exe' - const sourceFileName = 'orca-launcher.cs' - const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName) - const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName) - const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') - const binDir = joinRemotePath(env.hostPlatform, env.binDir) - return { - launcherPath, - files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }], - // Why: compiling on the Windows target avoids shipping an unsigned - // cross-host binary while ensuring argv never crosses cmd.exe's parser. - postWriteCommands: [ - createWindowsLauncherCompileCommand( - binDir, - sourceFileName, - launcherFileName, - launcherPath, - sourcePath, - legacyShimPath - ) - ] - } - } - - const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') - return { - launcherPath, - files: [ - { - path: launcherPath, - contents: [ - '#!/usr/bin/env sh', - 'set -eu', - `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, - `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, - `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, - `ORCA_RELAY_CREDENTIAL_FILE=\${ORCA_RELAY_CREDENTIAL_FILE:-${quoteSh(env.credentialFile ?? `${env.sockPath}.credential`)}}`, - 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', - ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', - ' exit 1', - 'fi', - 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --credential-file "$ORCA_RELAY_CREDENTIAL_FILE" --orca-cli "$@"', - '' - ].join('\n') - } - ], - // Surface chmod failures: a non-executable launcher must fail install loudly, not silently. - postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`] - } -} diff --git a/src/main/ssh/ssh-session-command-audit.test.ts b/src/main/ssh/ssh-session-command-audit.test.ts deleted file mode 100644 index 68d6f1c3a4c8..000000000000 --- a/src/main/ssh/ssh-session-command-audit.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - auditWindowsSessionCommands, - windowsSessionCommandViolations -} from './ssh-session-command-audit' -import { powerShellCommand } from './ssh-remote-powershell' - -describe('Windows session command audit', () => { - it('decodes the gzip self-extracting form of a long script', () => { - const long = `${'Write-Output "padding"\n'.repeat(400)}Add-Type -TypeDefinition 'x'` - const command = powerShellCommand(long) - expect(command).not.toContain('Add-Type') - expect(auditWindowsSessionCommands([command]).addTypeCommands).toBe(1) - }) - - it('needs no node.exe identity on a deploy that uploaded nothing', () => { - const audit = auditWindowsSessionCommands(['powershell.exe -NoProfile -Command "exit 0"']) - expect(windowsSessionCommandViolations(audit, { uploaded: false })).toEqual([]) - }) -}) diff --git a/src/main/ssh/ssh-session-command-audit.ts b/src/main/ssh/ssh-session-command-audit.ts deleted file mode 100644 index 40da0d99dd8c..000000000000 --- a/src/main/ssh/ssh-session-command-audit.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Audits the commands a pinned-Node deploy sent a Windows host, decoded the way the host runs - * them: runtime-compiled P/Invoke (`Add-Type`) is an EDR signal the pinned path must not emit - * (docs/reference/windows-edr-posture.md, design D5). - */ -import { decodeRemotePowerShellScript } from './ssh-remote-powershell' - -/** The stage-fencing prelude that reads file identities through the verified node.exe. */ -const NODE_STAGE_IDENTITY_MARKER = '$orcaIdentityNode' -const ADD_TYPE = /\bAdd-Type\b/iu - -export type WindowsSessionCommandAudit = { - commandCount: number - addTypeCommands: number - nodeStageIdentityCommands: number -} - -export function auditWindowsSessionCommands( - commands: readonly string[] -): WindowsSessionCommandAudit { - const scripts = commands.map((command) => decodeRemotePowerShellScript(command)) - return { - commandCount: scripts.length, - addTypeCommands: scripts.filter((script) => ADD_TYPE.test(script)).length, - nodeStageIdentityCommands: scripts.filter((script) => - script.includes(NODE_STAGE_IDENTITY_MARKER) - ).length - } -} - -/** Empty when the audit holds; `uploaded` means this deploy staged files onto the host. */ -export function windowsSessionCommandViolations( - audit: WindowsSessionCommandAudit, - options: { uploaded: boolean } -): string[] { - const violations: string[] = [] - if (audit.addTypeCommands > 0) { - violations.push(`${audit.addTypeCommands} of ${audit.commandCount} commands ran Add-Type`) - } - if (options.uploaded && audit.nodeStageIdentityCommands === 0) { - violations.push('no upload stage read file identities through the pinned node.exe') - } - return violations -} diff --git a/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts b/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts deleted file mode 100644 index c364b5a5d910..000000000000 --- a/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' - -describe('scanForShellReadyMarker', () => { - it('strips the marker and preserves surrounding output', () => { - const state = createShellReadyMarkerScanState() - - expect(scanForShellReadyMarker(state, 'before \x1b]777;orca-shell-ready\x07 after')).toEqual({ - output: 'before after', - matched: true - }) - }) - - it('matches markers split across chunks', () => { - const state = createShellReadyMarkerScanState() - - expect(scanForShellReadyMarker(state, 'before \x1b]777;orca')).toEqual({ - output: 'before ', - matched: false - }) - expect(scanForShellReadyMarker(state, '-shell-ready\x07 after')).toEqual({ - output: ' after', - matched: true - }) - }) - - it('flushes marker-like output when the full marker is not BEL-terminated', () => { - const state = createShellReadyMarkerScanState() - - expect(scanForShellReadyMarker(state, 'before \x1b]777;orca-shell-readyx')).toEqual({ - output: 'before \x1b]777;orca-shell-readyx', - matched: false - }) - expect(scanForShellReadyMarker(state, ' after')).toEqual({ - output: ' after', - matched: false - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts b/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts deleted file mode 100644 index 9b5b71956dfc..000000000000 --- a/src/renderer/src/components/terminal-pane/shell-ready-marker-scan.ts +++ /dev/null @@ -1,54 +0,0 @@ -const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready' - -export type ShellReadyMarkerScanState = { - matchPos: number - heldBytes: string -} - -export function createShellReadyMarkerScanState(): ShellReadyMarkerScanState { - return { matchPos: 0, heldBytes: '' } -} - -export function scanForShellReadyMarker( - state: ShellReadyMarkerScanState, - data: string -): { output: string; matched: boolean } { - let output = '' - - for (let i = 0; i < data.length; i += 1) { - const ch = data[i] as string - if (state.matchPos < SHELL_READY_MARKER.length) { - if (ch === SHELL_READY_MARKER[state.matchPos]) { - state.heldBytes += ch - state.matchPos += 1 - } else { - output += state.heldBytes - state.heldBytes = '' - state.matchPos = 0 - if (ch === SHELL_READY_MARKER[0]) { - state.heldBytes = ch - state.matchPos = 1 - } else { - output += ch - } - } - } else if (ch === '\x07') { - const remaining = data.slice(i + 1) - state.heldBytes = '' - state.matchPos = 0 - return { output: output + remaining, matched: true } - } else { - output += state.heldBytes - state.heldBytes = '' - state.matchPos = 0 - if (ch === SHELL_READY_MARKER[0]) { - state.heldBytes = ch - state.matchPos = 1 - } else { - output += ch - } - } - } - - return { output, matched: false } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts deleted file mode 100644 index 10fb67c94cc5..000000000000 --- a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ state: {} as Record })) - -vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) - -import { captureRuntimeTerminalDropOwner } from './terminal-drop-runtime-owner' - -describe('runtime terminal drop owner', () => { - beforeEach(() => { - mocks.state = { - settings: { activeRuntimeEnvironmentId: 'hub-a' }, - runtimeEnvironments: [{ id: 'hub-a' }], - runtimeEnvironmentCatalogHydrated: true, - removedRuntimeEnvironmentIds: new Set(), - repos: [{ id: 'repo-a', executionHostId: 'runtime:hub-a', connectionId: null }], - worktreesByRepo: { - 'repo-a': [ - { - id: 'worktree-a', - repoId: 'repo-a', - hostId: 'ssh:ssh-a', - runtimeOwnerEnvironmentId: 'hub-a' - } - ] - }, - detectedWorktreesByRepo: {}, - sshConnectionStates: new Map(), - sshStateByEnvironment: new Map([ - ['hub-a', { connectionStates: new Map([['ssh-a', { connectionGeneration: 17 }]]) }] - ]) - } - }) - - it('rejects a staged upload after the HUB SSH session token changes', () => { - const owner = captureRuntimeTerminalDropOwner('worktree-a') - expect(owner).toMatchObject({ - runtimeEnvironmentId: 'hub-a', - expectedSshTargetId: 'ssh-a', - expectedSshConnectionGeneration: 17 - }) - - mocks.state.sshStateByEnvironment = new Map([ - ['hub-a', { connectionStates: new Map([['ssh-a', { connectionGeneration: 18 }]]) }] - ]) - - expect(() => owner?.assertCurrent()).toThrow('Terminal upload host changed; retry the drop.') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts deleted file mode 100644 index 49f77a80d421..000000000000 --- a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { useAppStore } from '@/store' - -export function captureRuntimeTerminalDropOwner(worktreeId: string): - | ({ - runtimeEnvironmentId: string - assertCurrent: () => void - } & ReturnType) - | null { - const state = useAppStore.getState() - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (!runtimeEnvironmentId) { - return null - } - const expectation = captureWorktreeSshMutationExpectation(state, worktreeId) - const assertCurrent = (): void => { - const currentState = useAppStore.getState() - const currentRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(currentState, worktreeId) - const currentExpectation = captureWorktreeSshMutationExpectation(currentState, worktreeId) - if ( - currentRuntimeEnvironmentId !== runtimeEnvironmentId || - currentExpectation.expectedExecutionHostId !== expectation.expectedExecutionHostId || - currentExpectation.expectedSshTargetId !== expectation.expectedSshTargetId || - currentExpectation.expectedSshConnectionGeneration !== - expectation.expectedSshConnectionGeneration - ) { - throw new Error('Terminal upload host changed; retry the drop.') - } - } - return { runtimeEnvironmentId, assertCurrent, ...expectation } -} diff --git a/src/shared/issue-link-input.test.ts b/src/shared/issue-link-input.test.ts deleted file mode 100644 index bf026edc43f3..000000000000 --- a/src/shared/issue-link-input.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { getIssueLinkProviderFromUrl, parseIssueLinkInput } from './issue-link-input' - -describe('getIssueLinkProviderFromUrl', () => { - it('detects GitHub issue URLs', () => { - expect(getIssueLinkProviderFromUrl('https://github.com/o/r/issues/12')).toBe('github') - }) - - it('does not flip the provider for a GitHub pull URL', () => { - expect(getIssueLinkProviderFromUrl('https://github.com/o/r/pull/12')).toBeNull() - }) - - it('detects Linear issue URLs with and without a slug', () => { - expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335')).toBe('linear') - expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335/some-slug')).toBe( - 'linear' - ) - }) - - it('ignores non-issue paths on the Linear host', () => { - expect(getIssueLinkProviderFromUrl('https://linear.app/acme/team/ENG/all')).toBeNull() - }) - - it('rejects hosts that merely contain linear.app', () => { - expect(getIssueLinkProviderFromUrl('https://linear.app.evil.com/acme/issue/STA-335')).toBeNull() - }) - - // Linear and Jira issue-key shapes are byte-identical, so a bare key must - // never override the user's explicit provider choice. - it('is not decisive for bare issue keys', () => { - expect(getIssueLinkProviderFromUrl('STA-335')).toBeNull() - expect(getIssueLinkProviderFromUrl(' STA-335 ')).toBeNull() - }) - - it('is not decisive for bare numbers', () => { - expect(getIssueLinkProviderFromUrl('1234')).toBeNull() - expect(getIssueLinkProviderFromUrl('#1234')).toBeNull() - }) - - it('returns null for empty input', () => { - expect(getIssueLinkProviderFromUrl('')).toBeNull() - expect(getIssueLinkProviderFromUrl(' ')).toBeNull() - }) -}) - -describe('parseIssueLinkInput', () => { - describe('github provider', () => { - it('accepts bare and hash-prefixed numbers', () => { - expect(parseIssueLinkInput('12', 'github')).toEqual({ provider: 'github', number: 12 }) - expect(parseIssueLinkInput('#12', 'github')).toEqual({ provider: 'github', number: 12 }) - }) - - it('accepts issue URLs', () => { - expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'github')).toEqual({ - provider: 'github', - number: 12 - }) - }) - - it('rejects pull URLs', () => { - expect(parseIssueLinkInput('https://github.com/o/r/pull/12', 'github')).toBeNull() - }) - - it('rejects non-positive numbers and junk', () => { - expect(parseIssueLinkInput('0', 'github')).toBeNull() - expect(parseIssueLinkInput('-1', 'github')).toBeNull() - expect(parseIssueLinkInput('not an issue', 'github')).toBeNull() - expect(parseIssueLinkInput(' ', 'github')).toBeNull() - }) - - it('rejects Linear identifiers', () => { - expect(parseIssueLinkInput('STA-335', 'github')).toBeNull() - }) - - // Past the safe-integer range every digit string parses to the same float, - // so an unbounded parse would link an arbitrary issue number. - it('rejects numbers beyond the safe-integer range', () => { - expect(parseIssueLinkInput('9'.repeat(400), 'github')).toBeNull() - }) - }) - - describe('linear provider', () => { - it('accepts bare identifiers and normalizes case', () => { - expect(parseIssueLinkInput('STA-335', 'linear')).toEqual({ - provider: 'linear', - identifier: 'STA-335' - }) - expect(parseIssueLinkInput('sta-335', 'linear')).toEqual({ - provider: 'linear', - identifier: 'STA-335' - }) - }) - - it('omits the organization key for bare identifiers', () => { - expect(parseIssueLinkInput('STA-335', 'linear')).not.toHaveProperty('organizationUrlKey') - }) - - it('accepts issue URLs and returns the organization key', () => { - expect( - parseIssueLinkInput('https://linear.app/acme/issue/STA-335/some-slug', 'linear') - ).toEqual({ - provider: 'linear', - identifier: 'STA-335', - organizationUrlKey: 'acme' - }) - }) - - it('rejects GitHub URLs', () => { - expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'linear')).toBeNull() - }) - - it('rejects junk and empty input', () => { - expect(parseIssueLinkInput('not an issue', 'linear')).toBeNull() - expect(parseIssueLinkInput(' ', 'linear')).toBeNull() - }) - }) -}) diff --git a/src/shared/issue-link-input.ts b/src/shared/issue-link-input.ts deleted file mode 100644 index 1ff6c4ed3903..000000000000 --- a/src/shared/issue-link-input.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { parseGitHubIssueOrPRLink } from './github/links' -import { parseLinearIssueInput } from './linear/links' -import type { WorkspaceSourceProvider } from './new-workspace/workspace-source' - -// Why: narrows the canonical provider union instead of minting a parallel one, -// so adding Jira here is a one-entry change rather than a new axis. -export const ISSUE_LINK_PROVIDERS = [ - 'github', - 'linear' -] as const satisfies readonly WorkspaceSourceProvider[] - -export type IssueLinkProvider = (typeof ISSUE_LINK_PROVIDERS)[number] - -/** URL input only. Linear and Jira issue keys are byte-identical in shape, so a - * bare `STA-335` can never decide a provider — it would override the chip. */ -export function getIssueLinkProviderFromUrl(input: string): IssueLinkProvider | null { - const trimmed = input.trim() - if (!/^https?:\/\//i.test(trimmed)) { - return null - } - - // Why: a URL is decisive only if it names that provider's *issue*. A GitHub - // pull URL is a valid GitHub link but not an issue, and must not flip the - // provider into a state where the field then refuses to save it. - if (parseGitHubIssueOrPRLink(trimmed)?.type === 'issue') { - return 'github' - } - if (parseLinearIssueInput(trimmed)) { - return 'linear' - } - return null -} - -export type ParsedIssueLinkInput = - | { provider: 'github'; number: number } - | { provider: 'linear'; identifier: string; organizationUrlKey?: string } - -/** - * Single parse shared by the dialog's save gate and its payload builder, so a - * value that enables Save is always the value that gets written. - */ -export function parseIssueLinkInput( - input: string, - provider: IssueLinkProvider -): ParsedIssueLinkInput | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - if (provider === 'linear') { - const parsed = parseLinearIssueInput(trimmed) - return parsed ? { provider: 'linear', ...parsed } : null - } - - const link = parseGitHubIssueOrPRLink(trimmed) - if (link) { - // Why: issue and PR numbers live in separate GitHub namespaces for refs; a - // pull URL pasted here must not silently become an issue link. - return link.type === 'issue' ? { provider: 'github', number: link.number } : null - } - - const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed - if (!/^\d+$/.test(numeric)) { - return null - } - const parsed = Number.parseInt(numeric, 10) - // Why: `/^\d+$/` happily accepts 400 digits, which parseInt turns into - // Infinity — Save would enable and JSON.stringify would persist `null`. - return Number.isSafeInteger(parsed) && parsed > 0 ? { provider: 'github', number: parsed } : null -} diff --git a/tests/e2e/orcad-serve-mode-switch.spec.ts b/tests/e2e/orcad-serve-mode-switch.spec.ts index 04691643906b..de659c6fc0ed 100644 --- a/tests/e2e/orcad-serve-mode-switch.spec.ts +++ b/tests/e2e/orcad-serve-mode-switch.spec.ts @@ -18,6 +18,7 @@ import { runProcess } from '@orca/process-host' import { getDaemonPidPath } from '../../src/main/daemon/daemon-spawner' import { readDaemonPidRecord } from '../../src/main/daemon/daemon-endpoint-incarnation' import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime' +import { ORCAD_EXIT_CONFIGURATION } from '../../src/main/orcad/orcad-configuration-exit' import { buildDaemonSessionClient, type DaemonSessionClientResult @@ -40,7 +41,6 @@ const RUN = process.env.ORCA_E2E_ORCAD_SERVE === '1' const slotDir = path.resolve('out/orcad') const orcadRuntime = RUN && existsSync(slotDir) ? resolveBundledOrcadRuntime(slotDir) : null const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3 -const ORCAD_EXIT_CONFIGURATION = 78 test.describe.configure({ mode: 'serial' }) From 6ad1a670a378975d4f22fbfb517d11da8c155e78 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:08:01 -0700 Subject: [PATCH 45/58] refactor(rpc): delete the SSH-bridge caller scope (#27440) Nothing mints an SSH-bridge credential since the relay client was removed (#27214), so the 'ssh-bridge' caller kind, its host binders, result filter, the CLI bridge-mode policy and the ORCA_SSH_BRIDGE_CREDENTIAL env read could never run. The allowRemoteCliControl target field stays for older peers. Co-authored-by: m4air --- config/scripts/ci-shard-timings.json | 4 - src/cli/index.ts | 8 - .../transport-ssh-bridge-credential.test.ts | 53 --- src/cli/runtime/transport.ts | 4 +- src/cli/ssh-bridge-command-policy.test.ts | 51 --- src/cli/ssh-bridge-command-policy.ts | 60 --- .../rpc/dispatcher-transport-parity.test.ts | 4 +- src/main/runtime/rpc/dispatcher.ts | 11 +- .../rpc/methods/ssh-target-management.test.ts | 13 - src/main/runtime/rpc/rpc-caller-scope.test.ts | 196 +-------- src/main/runtime/rpc/rpc-caller-scope.ts | 46 +-- .../rpc/rpc-request-admission-prologue.ts | 13 +- .../runtime/rpc/rpc-streaming-dispatcher.ts | 16 +- .../runtime/rpc/ssh-bridge-credentials.ts | 32 -- .../runtime/rpc/ssh-bridge-host-binding.ts | 155 -------- .../ssh-bridge-orchestration-binding.test.ts | 371 ------------------ .../rpc/ssh-bridge-orchestration-binding.ts | 166 -------- .../runtime-rpc-ssh-bridge-credential.test.ts | 62 --- .../runtime-rpc-request-admission.ts | 6 - .../ssh-bridge-caller-scope.test-fixture.ts | 15 - src/shared/ssh-bridge-credential-env.ts | 12 - 21 files changed, 17 insertions(+), 1281 deletions(-) delete mode 100644 src/cli/runtime/transport-ssh-bridge-credential.test.ts delete mode 100644 src/cli/ssh-bridge-command-policy.test.ts delete mode 100644 src/cli/ssh-bridge-command-policy.ts delete mode 100644 src/main/runtime/rpc/ssh-bridge-credentials.ts delete mode 100644 src/main/runtime/rpc/ssh-bridge-host-binding.ts delete mode 100644 src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts delete mode 100644 src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts delete mode 100644 src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts delete mode 100644 src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts delete mode 100644 src/shared/ssh-bridge-credential-env.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 57f6977cfa8c..929ac64556c5 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -459,7 +459,6 @@ "src/cli/runtime/status-caller.test.ts": 660, "src/cli/runtime/status.test.ts": 377, "src/cli/runtime/transport-framing.test.ts": 337, - "src/cli/runtime/transport-ssh-bridge-credential.test.ts": 248, "src/cli/runtime/transport.test.ts": 789, "src/cli/runtime/types.test.ts": 48, "src/cli/runtime/websocket-transport-error.test.ts": 375, @@ -474,7 +473,6 @@ "src/cli/specs/file.test.ts": 97, "src/cli/specs/profile-state.test.ts": 87, "src/cli/specs/search.test.ts": 181, - "src/cli/ssh-bridge-command-policy.test.ts": 70, "src/cli/terminal-format-draft.test.ts": 51, "src/cli/terminal-format.test.ts": 78, "src/cli/terminal-list-host-scope-format.test.ts": 80, @@ -4855,7 +4853,6 @@ "src/main/runtime/rpc/runtime-close-attribution.test.ts": 937, "src/main/runtime/rpc/runtime-source-admission.test.ts": 4337, "src/main/runtime/rpc/schemas.test.ts": 1500, - "src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts": 5627, "src/main/runtime/rpc/static-web-client-handler-stream.test.ts": 1767, "src/main/runtime/rpc/streaming.test.ts": 690, "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 611, @@ -4998,7 +4995,6 @@ "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 6365, "src/main/runtime/runtime-rpc-relay-provider-install.test.ts": 5568, "src/main/runtime/runtime-rpc-request-authorization.test.ts": 6177, - "src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts": 5067, "src/main/runtime/runtime-rpc-startup-failure.test.ts": 93, "src/main/runtime/runtime-rpc-terminal-list.test.ts": 5576, "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 5192, diff --git a/src/cli/index.ts b/src/cli/index.ts index b51041edec3c..91731d94d497 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,10 +21,6 @@ import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' import { refuseConflictingSessionCallerFlags } from './session-caller-flags' -import { - refuseOwnerOnlySshBridgeCommand, - refuseSshBridgeEnvironmentRouting -} from './ssh-bridge-command-policy' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -117,8 +113,6 @@ export async function main( // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) const commandSpec = findCommandSpec(COMMAND_SPECS, parsed.commandPath) - // Why: the canonical path, so an alias cannot slip an owner-only command past the bridge. - refuseOwnerOnlySshBridgeCommand(commandSpec?.path ?? parsed.commandPath, parsed.flags) refuseConflictingSessionCallerFlags(commandSpec, parsed.flags) const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) @@ -156,7 +150,6 @@ export async function main( // Why: --host runtime: is canonicalized to the environment's id so downstream host-id // comparisons against stored rows still match; rewrite the flag once, here, rather than // resolving the name again at every consumer. - refuseSshBridgeEnvironmentRouting(hostEnvironmentId) if (hostEnvironmentId !== null) { parsed.flags.set('host', `runtime:${hostEnvironmentId}`) } @@ -201,7 +194,6 @@ export async function main( async function runClaudeTeams(argv: string[], cwd: string): Promise { try { - refuseOwnerOnlySshBridgeCommand(['claude-teams'], new Map()) // Why: everything after `orca claude-teams` belongs to Claude Code, not // Orca's own flag parser, so new Claude flags work without Orca changes. const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null) diff --git a/src/cli/runtime/transport-ssh-bridge-credential.test.ts b/src/cli/runtime/transport-ssh-bridge-credential.test.ts deleted file mode 100644 index bcfcdbde90f5..000000000000 --- a/src/cli/runtime/transport-ssh-bridge-credential.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createServer } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' -import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env' -import { sendRequest } from './transport' - -afterEach(() => { - vi.unstubAllEnvs() -}) - -async function captureAuthToken(): Promise { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-bridge-transport-')) - const endpoint = join(userDataPath, 'runtime.sock') - let authToken = '' - const server = createServer((socket) => { - socket.once('data', (data) => { - const request: { id: string; authToken: string } = JSON.parse(String(data).trim()) - authToken = request.authToken - socket.end( - `${JSON.stringify({ id: request.id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })}\n` - ) - }) - }) - await new Promise((resolve) => server.listen(endpoint, resolve)) - const metadata: RuntimeMetadata = { - runtimeId: 'runtime-1', - pid: 123, - transports: [{ kind: 'unix', endpoint }], - authToken: 'owner-token', - startedAt: 1 - } - try { - await sendRequest(metadata, 'status.get', undefined, 5_000) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } - return authToken -} - -describe.skipIf(process.platform === 'win32')('runtime transport credentials', () => { - it('presents the SSH bridge credential instead of the owner token when bridged', async () => { - vi.stubEnv(ORCA_SSH_BRIDGE_CREDENTIAL_ENV, 'sshb_scoped') - expect(await captureAuthToken()).toBe('sshb_scoped') - }) - - it('presents the owner token outside the bridge', async () => { - vi.stubEnv(ORCA_SSH_BRIDGE_CREDENTIAL_ENV, '') - expect(await captureAuthToken()).toBe('owner-token') - }) -}) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index fc38e500bd9d..594d8a752a43 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,4 +1,3 @@ -import { readSshBridgeCredential } from '../../shared/ssh-bridge-credential-env' import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' @@ -202,8 +201,7 @@ export async function sendRequest( socket.write( `${JSON.stringify({ id: requestId, - // Why: a bridged SSH invocation must present its scoped credential, never the owner token. - authToken: readSshBridgeCredential() ?? metadata.authToken, + authToken: metadata.authToken, method, params, orchestrationCapability: envelope?.orchestrationCapability, diff --git a/src/cli/ssh-bridge-command-policy.test.ts b/src/cli/ssh-bridge-command-policy.test.ts deleted file mode 100644 index 2b54cb72d681..000000000000 --- a/src/cli/ssh-bridge-command-policy.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../shared/ssh-bridge-credential-env' -import { - refuseOwnerOnlySshBridgeCommand, - refuseSshBridgeEnvironmentRouting -} from './ssh-bridge-command-policy' - -const BRIDGED = { [ORCA_SSH_BRIDGE_CREDENTIAL_ENV]: 'sshb_test' } - -describe('owner-only commands over the SSH bridge', () => { - it.each([ - [['account', 'add']], - [['environment', 'add']], - [['skills', 'install']], - [['agent', 'hooks', 'on']], - [['profile', 'state', 'rollback']], - [['serve']], - [['claude-teams']] - ])('refuses %j, which acts on the Orca host directly', (commandPath) => { - expect(() => refuseOwnerOnlySshBridgeCommand(commandPath, new Map(), BRIDGED)).toThrow( - /cannot run from an SSH host/ - ) - }) - - it.each([['environment'], ['pairing-code']])( - 'refuses --%s, which routes through the host’s paired servers', - (flag) => { - expect(() => - refuseOwnerOnlySshBridgeCommand(['terminal', 'list'], new Map([[flag, 'prod']]), BRIDGED) - ).toThrow(/paired servers/) - } - ) - - it('refuses --host runtime: routing', () => { - expect(() => refuseSshBridgeEnvironmentRouting('env-1', BRIDGED)).toThrow(/paired servers/) - }) - - it('leaves runtime-routed commands to the scoped runtime socket', () => { - expect(() => - refuseOwnerOnlySshBridgeCommand(['terminal', 'list'], new Map(), BRIDGED) - ).not.toThrow() - expect(() => - refuseOwnerOnlySshBridgeCommand(['skills', 'list'], new Map(), BRIDGED) - ).not.toThrow() - }) - - it('does nothing outside the bridge', () => { - expect(() => refuseOwnerOnlySshBridgeCommand(['account', 'add'], new Map(), {})).not.toThrow() - expect(() => refuseSshBridgeEnvironmentRouting('env-1', {})).not.toThrow() - }) -}) diff --git a/src/cli/ssh-bridge-command-policy.ts b/src/cli/ssh-bridge-command-policy.ts deleted file mode 100644 index 8ac11e614f71..000000000000 --- a/src/cli/ssh-bridge-command-policy.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { readSshBridgeCredential } from '../shared/ssh-bridge-credential-env' -import { RuntimeClientError } from './runtime/types' - -// Why: these act on this machine directly (its files, accounts, processes) rather than through the -// scoped runtime socket, so the runtime's per-caller permissions can never see them. -const OWNER_MACHINE_COMMANDS: readonly (readonly string[])[] = [ - ['account'], - ['agent', 'hooks'], - ['claude-teams'], - ['environment'], - ['host', 'list'], - ['profile'], - ['serve'], - ['skills', 'install'], - ['skills', 'update'], - ['skills', 'share'], - ['vm'] -] - -function isSshBridgeInvocation(env: NodeJS.ProcessEnv = process.env): boolean { - return readSshBridgeCredential(env) !== null -} - -/** Throws when an SSH-bridged invocation asks for something only this machine's owner may do. */ -export function refuseOwnerOnlySshBridgeCommand( - commandPath: readonly string[], - flags: ReadonlyMap, - env: NodeJS.ProcessEnv = process.env -): void { - if (!isSshBridgeInvocation(env)) { - return - } - const command = commandPath.join(' ') - if (OWNER_MACHINE_COMMANDS.some((prefix) => prefix.every((part, i) => commandPath[i] === part))) { - throw new RuntimeClientError( - 'forbidden', - `orca ${command} acts on the Orca host machine itself and cannot run from an SSH host. Run it on the Orca host.` - ) - } - for (const flag of ['environment', 'pairing-code']) { - if (flags.has(flag)) { - throw new RuntimeClientError( - 'forbidden', - `--${flag} would route through the Orca host's own paired servers and cannot be used from an SSH host.` - ) - } - } -} - -export function refuseSshBridgeEnvironmentRouting( - hostEnvironmentId: string | null, - env: NodeJS.ProcessEnv = process.env -): void { - if (hostEnvironmentId !== null && isSshBridgeInvocation(env)) { - throw new RuntimeClientError( - 'forbidden', - "--host runtime: would route through the Orca host's own paired servers and cannot be used from an SSH host." - ) - } -} diff --git a/src/main/runtime/rpc/dispatcher-transport-parity.test.ts b/src/main/runtime/rpc/dispatcher-transport-parity.test.ts index 371ebb8587df..e9eb767fd2c2 100644 --- a/src/main/runtime/rpc/dispatcher-transport-parity.test.ts +++ b/src/main/runtime/rpc/dispatcher-transport-parity.test.ts @@ -171,7 +171,7 @@ describe('RpcDispatcher unary transport parity', () => { } ) - it('refuses a streaming method over one-shot dispatch before binding an SSH bridge caller', async () => { + it('refuses a streaming method over one-shot dispatch for a pinned non-owner caller', async () => { const dispatcher = new RpcDispatcher({ runtime: new OrcaRuntimeService(), methods: [ @@ -182,7 +182,7 @@ describe('RpcDispatcher unary transport parity', () => { handler: async () => {} }) ], - callerScope: { kind: 'ssh-bridge', targetId: 'ssh-1', remoteCliControl: false } + callerScope: { kind: 'runtime-paired', grants: [] } }) await expect( diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 478c2db953bc..2fa14f238e11 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -10,7 +10,7 @@ import { type RpcStreamingMethod } from './core' -import { successResponse, errorResponse } from './errors' +import { successResponse } from './errors' import { ALL_RPC_METHODS } from './methods' import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' @@ -97,14 +97,7 @@ export class RpcDispatcher { orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration }) - const filtered = admission.binding?.filterResult?.(result) ?? { - kind: 'allowed', - result - } - if (filtered.kind === 'denied') { - return errorResponse(request.id, meta, 'forbidden', filtered.message) - } - return successResponse(request.id, meta, filtered.result) + return successResponse(request.id, meta, result) } catch (error) { if (request.method.startsWith('emulator.')) { emulatorProbeError(`rpc ${request.method}`, error, { diff --git a/src/main/runtime/rpc/methods/ssh-target-management.test.ts b/src/main/runtime/rpc/methods/ssh-target-management.test.ts index ddba0166d402..a4989d9dc350 100644 --- a/src/main/runtime/rpc/methods/ssh-target-management.test.ts +++ b/src/main/runtime/rpc/methods/ssh-target-management.test.ts @@ -287,18 +287,5 @@ describe('server SSH target management RPC', () => { ok: false }) }) - - it.each(MANAGEMENT_CALLS)( - "an SSH host's CLI without remote control may not call %s", - async (method, params) => { - await expect( - dispatch(method, params, { - kind: 'ssh-bridge', - targetId: 'ssh-1', - remoteCliControl: false - }) - ).resolves.toMatchObject({ ok: false }) - } - ) }) }) diff --git a/src/main/runtime/rpc/rpc-caller-scope.test.ts b/src/main/runtime/rpc/rpc-caller-scope.test.ts index e570a5d2bb07..3b3b7a9e323e 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.test.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.test.ts @@ -1,16 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime' -import type { RuntimeTerminalListResult } from '../../../shared/runtime-terminal-contracts' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ALL_RPC_METHODS } from './methods' import type { RpcCallerScope } from './rpc-caller-scope' import { RPC_METHOD_PERMISSIONS } from './rpc-method-permission' -import { SSH_BRIDGE_HOST_BINDERS } from './ssh-bridge-host-binding' -import { - CONTROL_GRANTED_SSH_BRIDGE_SCOPE as CONTROL_GRANTED, - HOST_BOUND_SSH_BRIDGE_SCOPE as HOST_BOUND -} from '../../ssh/ssh-bridge-caller-scope.test-fixture' const PAIRED: RpcCallerScope = { kind: 'runtime-paired', grants: [] } @@ -30,46 +24,10 @@ async function dispatchAs( ) } -async function dispatchStreamingAs( - scope: RpcCallerScope, - method: string, - params?: unknown -): Promise { - const replies: RpcResponse[] = [] - await new RpcDispatcher({ runtime: new OrcaRuntimeService() }).dispatchStreaming( - request(method, params), - (raw) => replies.push(JSON.parse(raw)), - { callerScope: scope } - ) - const [reply] = replies - if (!reply) { - throw new Error('no reply') - } - return reply -} - function errorCode(response: RpcResponse): string | null { return response.ok ? null : response.error.code } -function terminalSummary(handle: string, executionHostId: 'ssh:box-1' | 'ssh:box-2' | 'local') { - return { - handle, - ptyId: null, - worktreeId: `wt-${handle}`, - worktreePath: `/wt/${handle}`, - branch: 'main', - tabId: 'tab', - leafId: 'leaf', - title: null, - connected: true, - writable: true, - lastOutputAt: null, - preview: `secret output of ${handle}`, - executionHostId - } as const -} - describe('every RPC method declares a known permission', () => { it('rejects nothing outside the permission vocabulary', () => { for (const method of ALL_RPC_METHODS) { @@ -92,156 +50,6 @@ describe('every RPC method declares a known permission', () => { }) }) -describe('SSH bridge without the per-host opt-in', () => { - it('binds only registered workspace methods', () => { - const permissionOf = (name: string) => ALL_RPC_METHODS.find((m) => m.name === name)?.permission - for (const method of SSH_BRIDGE_HOST_BINDERS.keys()) { - expect(permissionOf(method), method).toBe('workspace') - } - }) - - it.each([ - ['computer.click', { app: 'Finder' }], - ['accounts.selectClaude', {}], - ['settings.update', {}], - ['skills.install', {}], - ['terminal.create', {}], - ['orchestration.dispatch', { task: 'task_1' }], - ['files.read', {}] - ])('refuses %s before any handler runs', async (method, params) => { - const response = await dispatchAs(HOST_BOUND, method, params) - expect(errorCode(response)).toBe('forbidden') - }) - - it('enforces the same refusal on the streaming transport', async () => { - const response = await dispatchStreamingAs(HOST_BOUND, 'computer.click', { app: 'Finder' }) - expect(errorCode(response)).toBe('forbidden') - }) - - it("refuses a terminal handle that resolves to another host's terminal", async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ - ...terminalSummary('term_other', 'ssh:box-2'), - paneRuntimeId: 1, - rendererGraphEpoch: 0 - }) - const read = vi.spyOn(runtime, 'readTerminal') - const response = await dispatchAs( - HOST_BOUND, - 'terminal.read', - { terminal: 'term_other' }, - runtime - ) - expect(errorCode(response)).toBe('forbidden') - expect(read).not.toHaveBeenCalled() - }) - - it('refuses a terminal whose host cannot be named', async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'showTerminal').mockRejectedValue(new Error('terminal_not_found')) - const send = vi.spyOn(runtime, 'sendTerminal') - const response = await dispatchAs( - HOST_BOUND, - 'terminal.send', - { terminal: 'term_unknown', text: 'rm -rf ~' }, - runtime - ) - expect(errorCode(response)).toBe('forbidden') - expect(send).not.toHaveBeenCalled() - }) - - it("reads the bridged host's own terminal", async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ - ...terminalSummary('term_own', 'ssh:box-1'), - paneRuntimeId: 1, - rendererGraphEpoch: 0 - }) - vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle: 'term_own', - status: 'running', - tail: ['ok'], - truncated: false, - nextCursor: null - }) - const response = await dispatchAs( - HOST_BOUND, - 'terminal.read', - { terminal: 'term_own' }, - runtime - ) - expect(response.ok).toBe(true) - }) - - it("lists only the bridged host's terminals", async () => { - const runtime = new OrcaRuntimeService() - const listing: RuntimeTerminalListResult = { - terminals: [ - terminalSummary('term_own', 'ssh:box-1'), - terminalSummary('term_local', 'local'), - terminalSummary('term_other', 'ssh:box-2') - ], - totalCount: 3, - truncated: false, - topologyRevisions: { 'wt-term_own': 1, 'wt-term_local': 2 }, - hostScope: { hostIds: ['local', 'ssh:box-1', 'ssh:box-2'], omittedHostIds: [] } - } - vi.spyOn(runtime, 'listTerminals').mockResolvedValue(listing) - const response = await dispatchAs(HOST_BOUND, 'terminal.list', {}, runtime) - if (!response.ok) { - throw new Error(response.error.message) - } - expect(response.result).toEqual({ - terminals: [terminalSummary('term_own', 'ssh:box-1')], - totalCount: 1, - truncated: false, - topologyRevisions: { 'wt-term_own': 1 }, - hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: [] } - }) - }) - - it("refuses a terminal listing selected by another host's worktree", async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [terminalSummary('term_local', 'local')], - totalCount: 1, - truncated: false - }) - const response = await dispatchAs( - HOST_BOUND, - 'terminal.list', - { worktree: 'path:/Users/me/secret' }, - runtime - ) - expect(errorCode(response)).toBe('forbidden') - }) -}) - -describe('SSH bridge with the per-host opt-in', () => { - it('reaches workspace methods outside its own host', async () => { - const runtime = new OrcaRuntimeService() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [terminalSummary('term_local', 'local')], - totalCount: 1, - truncated: false - }) - const response = await dispatchAs(CONTROL_GRANTED, 'terminal.list', {}, runtime) - expect(response.ok).toBe(true) - }) - - it.each([ - ['computer.click', { app: 'Finder' }], - ['accounts.selectClaude', {}], - ['settings.update', {}], - ['skills.install', {}], - ['ssh.connect', {}], - ['pairing.provisionRelay', {}] - ])('still refuses owner-only %s', async (method, params) => { - const response = await dispatchAs(CONTROL_GRANTED, method, params) - expect(errorCode(response)).toBe('forbidden') - }) -}) - describe('paired runtime clients', () => { it('cannot drive the desktop without a pairing-time grant', async () => { const response = await dispatchAs(PAIRED, 'computer.click', { app: 'Finder' }) @@ -290,7 +98,7 @@ describe('a pinned in-process bridge dispatcher', () => { it('ignores a wider per-call scope', async () => { const dispatcher = new RpcDispatcher({ runtime: new OrcaRuntimeService(), - callerScope: HOST_BOUND + callerScope: PAIRED }) const response = await dispatcher.dispatch(request('computer.click', { app: 'Finder' }), { callerScope: { kind: 'owner' } diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts index 9b1bd33c0e31..f87c1e9ac881 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -1,19 +1,12 @@ /** * Which permissions each kind of caller holds, checked once by the dispatcher for every transport. * - * A transport stamps the scope it proved (device token, SSH bridge credential); an unstamped call is + * A transport stamps the scope it proved (device token); an unstamped call is * this host's own desktop or owner-token CLI. Every non-owner row is deny-by-default: a method is * reachable only when its declared permission is in the row. */ import { MOBILE_RPC_METHOD_ALLOWLIST } from '../runtime-rpc/runtime-rpc-mobile-method-allowlist' import type { RpcMethodPermission, RuntimeDeviceGrant } from './rpc-method-permission' -import type { OrcaRuntimeService } from '../orca-runtime' -import { - bindSshBridgeCall, - SSH_BRIDGE_HOST_BINDERS, - SSH_BRIDGE_REMOTE_CONTROL_HINT, - type SshBridgeCallBinding -} from './ssh-bridge-host-binding' export type { RuntimeDeviceGrant } @@ -23,9 +16,6 @@ export type RpcCallerScope = * desktop, which is never granted `pairing-admin`. */ | { kind: 'mobile'; delegated?: boolean } | { kind: 'runtime-paired'; grants: readonly RuntimeDeviceGrant[] } - /** The `orca` CLI of a remote SSH host, relayed through this host's bridge. `remoteCliControl` - * is the per-target opt-in that lets that host's CLI act outside its own terminals. */ - | { kind: 'ssh-bridge'; targetId: string; remoteCliControl: boolean } export const OWNER_RPC_CALLER_SCOPE: RpcCallerScope = { kind: 'owner' } @@ -63,34 +53,8 @@ export function denyRpcMethodForCaller( if (scope.kind === 'owner' || permission === undefined) { return null } - if (scope.kind === 'runtime-paired') { - return !RUNTIME_PAIRED_STANDARD.has(permission) && - !scope.grants.some((grant) => grant === permission) - ? `Method '${methodName}' needs the '${permission}' permission, which this paired client was not granted when it paired.` - : null - } - if (scope.remoteCliControl) { - return permission === 'workspace' - ? null - : `Method '${methodName}' is not available to an SSH host's orca CLI.` - } - return permission === 'workspace' && SSH_BRIDGE_HOST_BINDERS.has(methodName) - ? null - : `Method '${methodName}' is not available to the orca CLI on SSH host '${scope.targetId}'. ${SSH_BRIDGE_REMOTE_CONTROL_HINT}` -} - -/** - * Binds an allowed call's selectors to the caller's host; only the unopted SSH bridge is bound. - * Null (not a resolved promise) when unbound, so other callers' dispatch gains no extra tick. - */ -export function bindRpcCallToCallerScope( - scope: RpcCallerScope, - runtime: OrcaRuntimeService, - methodName: string, - params: unknown -): Promise | null { - if (scope.kind !== 'ssh-bridge' || scope.remoteCliControl) { - return null - } - return bindSshBridgeCall(runtime, scope.targetId, methodName, params) + return !RUNTIME_PAIRED_STANDARD.has(permission) && + !scope.grants.some((grant) => grant === permission) + ? `Method '${methodName}' needs the '${permission}' permission, which this paired client was not granted when it paired.` + : null } diff --git a/src/main/runtime/rpc/rpc-request-admission-prologue.ts b/src/main/runtime/rpc/rpc-request-admission-prologue.ts index 9d2f7b562c5a..bd4b33185166 100644 --- a/src/main/runtime/rpc/rpc-request-admission-prologue.ts +++ b/src/main/runtime/rpc/rpc-request-admission-prologue.ts @@ -13,9 +13,7 @@ import { runtimeSourceFence } from './runtime-source-admission' import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options' import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' -import type { SshBridgeCallBinding } from './ssh-bridge-host-binding' import { - bindRpcCallToCallerScope, denyRpcMethodForCaller, OWNER_RPC_CALLER_SCOPE, type RpcCallerScope @@ -33,7 +31,7 @@ type RpcRequestAdmissionDeps = { pinnedCallerScope?: RpcCallerScope } -// Why: both transports must reject, resolve and bind a request in the same order before any handler runs. +// Why: both transports must reject and resolve a request in the same order before any handler runs. export function admitRpcRequest( { runtime, registry, meta, pinnedCallerScope }: RpcRequestAdmissionDeps, rawRequest: RpcRequest, @@ -76,14 +74,7 @@ export function admitRpcRequest( ) ) } - const params = parsedParams.value - const admit = (binding: SshBridgeCallBinding | null) => - binding?.kind === 'denied' - ? reject(errorResponse(request.id, meta, 'forbidden', binding.message)) - : { method, request, caller, params, binding } - // Why: an unbound call stays synchronous, so dispatch gains no extra tick before the handler. - const pendingBinding = bindRpcCallToCallerScope(callerScope, runtime, request.method, params) - return pendingBinding ? pendingBinding.then(admit) : admit(null) + return { method, request, caller, params: parsedParams.value } } // Why: before params parse and the unary/streaming split, so both branches see one caller. return needsOrchestrationCallerResolution(rawRequest) diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 9a145ceed0f4..10c572d7ac27 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -7,7 +7,7 @@ import { type RpcStreamingMethod } from './core' -import { errorResponse, successResponse } from './errors' +import { successResponse } from './errors' import type { OrcaRuntimeService } from '../orca-runtime' import type { OrchestrationMutationExecutor } from './orchestration-mutation-executor' import { recordRuntimeFeatureInteraction } from './runtime-feature-interaction' @@ -60,7 +60,7 @@ export class RpcStreamingDispatcher { reply(JSON.stringify(admission.rejected)) return } - const { method, request, params, binding, caller } = admission + const { method, request, params, caller } = admission if (!isStreamingMethod(method)) { try { @@ -73,17 +73,7 @@ export class RpcStreamingDispatcher { orchestrationMutations, legacyOrchestration }) - const filtered = binding?.filterResult?.(result) ?? { - kind: 'allowed', - result - } - reply( - JSON.stringify( - filtered.kind === 'denied' - ? errorResponse(request.id, envelopeMeta, 'forbidden', filtered.message) - : successResponse(request.id, envelopeMeta, filtered.result) - ) - ) + reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) } catch (error) { reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) } diff --git a/src/main/runtime/rpc/ssh-bridge-credentials.ts b/src/main/runtime/rpc/ssh-bridge-credentials.ts deleted file mode 100644 index 4b5498806694..000000000000 --- a/src/main/runtime/rpc/ssh-bridge-credentials.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Short-lived credentials for the `orca` CLI an SSH host runs through this host's bridge. - * - * One credential per invocation, bound to the SSH target that relayed it, revoked when the - * invocation settles. The runtime socket maps it to that target's bridge scope, so the child CLI - * never holds the owner token. - */ -import { randomBytes } from 'node:crypto' -import type { RpcCallerScope } from './rpc-caller-scope' - -export type SshBridgeCallerScope = Extract - -export type SshBridgeCredential = { - token: string - revoke: () => void -} - -export class SshBridgeCredentialRegistry { - private readonly bindings = new Map() - - mint(scope: SshBridgeCallerScope): SshBridgeCredential { - const token = `sshb_${randomBytes(24).toString('hex')}` - this.bindings.set(token, { ...scope }) - return { token, revoke: () => this.bindings.delete(token) } - } - - resolve(token: string): SshBridgeCallerScope | null { - return this.bindings.get(token) ?? null - } -} - -export const sshBridgeCredentials = new SshBridgeCredentialRegistry() diff --git a/src/main/runtime/rpc/ssh-bridge-host-binding.ts b/src/main/runtime/rpc/ssh-bridge-host-binding.ts deleted file mode 100644 index a03a4c0e8c7d..000000000000 --- a/src/main/runtime/rpc/ssh-bridge-host-binding.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Keeps an SSH host's relayed `orca` CLI inside that host's own terminals. - * - * Without the per-target opt-in, the bridge reaches only these methods, and each selector must - * resolve to a terminal on the bridged host; orchestration callers are bound the same way. Terminals whose host cannot be named are refused: - * failing closed is the only safe answer for a credential that must not reach other hosts. - */ -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { RuntimeTerminalListResult } from '../../../shared/runtime-terminal-contracts' -import type { OrcaRuntimeService } from '../orca-runtime' -import { - findSshBridgeOrchestrationViolation, - isHostTerminal, - parseSshBridgeSelectors, - SSH_BRIDGE_ORCHESTRATION_METHODS, - type SshBridgeSelectors -} from './ssh-bridge-orchestration-binding' - -export const SSH_BRIDGE_REMOTE_CONTROL_HINT = - 'To let that host\'s CLI control this Orca, enable "Allow this host\'s orca CLI to control Orca" in Settings > SSH for that host.' - -export type SshBridgeResultFilter = ( - result: unknown -) => { kind: 'denied'; message: string } | { kind: 'allowed'; result: unknown } - -export type SshBridgeCallBinding = - | { kind: 'denied'; message: string } - | { kind: 'allowed'; filterResult?: SshBridgeResultFilter } - -type SshBridgeBinder = ( - runtime: OrcaRuntimeService, - targetId: string, - methodName: string, - selectors: SshBridgeSelectors -) => Promise - -const bindTerminalHandle: SshBridgeBinder = async (runtime, targetId, _methodName, selectors) => { - const handle = selectors.terminal - return handle && (await isHostTerminal(runtime, toSshExecutionHostId(targetId), handle)) - ? { kind: 'allowed' } - : { kind: 'denied', message: outsideHostMessage(targetId, `terminal '${handle ?? ''}'`) } -} - -const bindOrchestration: SshBridgeBinder = async (runtime, targetId, methodName, selectors) => { - const violation = await findSshBridgeOrchestrationViolation( - runtime, - toSshExecutionHostId(targetId), - methodName, - selectors - ) - return violation - ? { kind: 'denied', message: outsideHostMessage(targetId, violation) } - : { kind: 'allowed' } -} - -/** Every method an unopted bridge may reach, each with how its selectors are bound to the host. */ -export const SSH_BRIDGE_HOST_BINDERS: ReadonlyMap = new Map< - string, - SshBridgeBinder ->([ - ['status.get', async () => ({ kind: 'allowed' })], - [ - 'terminal.list', - async (_runtime, targetId, _methodName, { worktree }) => ({ - kind: 'allowed', - filterResult: (result) => filterTerminalListToHost(result, targetId, worktree) - }) - ], - ['terminal.show', bindTerminalHandle], - ['terminal.read', bindTerminalHandle], - ['terminal.send', bindTerminalHandle], - ['terminal.wait', bindTerminalHandle], - ...SSH_BRIDGE_ORCHESTRATION_METHODS.map((method): [string, SshBridgeBinder] => [ - method, - bindOrchestration - ]) -]) - -export function bindSshBridgeCall( - runtime: OrcaRuntimeService, - targetId: string, - methodName: string, - params: unknown -): Promise { - const bind = SSH_BRIDGE_HOST_BINDERS.get(methodName) - // Why: the dispatcher admits only table methods, so a miss is unreachable and must fail closed. - return bind - ? bind(runtime, targetId, methodName, parseSshBridgeSelectors(params)) - : Promise.resolve({ - kind: 'denied', - message: outsideHostMessage(targetId, `method '${methodName}'`) - }) -} - -function filterTerminalListToHost( - result: unknown, - targetId: string, - worktreeSelector: string | undefined -): ReturnType { - const hostId = toSshExecutionHostId(targetId) - if (!isTerminalListResult(result)) { - return { kind: 'denied', message: 'Terminal listing could not be scoped to the SSH host.' } - } - const terminals = result.terminals.filter((terminal) => terminal.executionHostId === hostId) - if (worktreeSelector && terminals.length !== result.terminals.length) { - return { - kind: 'denied', - message: outsideHostMessage(targetId, `worktree '${worktreeSelector}'`) - } - } - const worktreeIds = new Set(terminals.map((terminal) => terminal.worktreeId)) - const scoped: RuntimeTerminalListResult = { - terminals, - totalCount: terminals.length, - truncated: result.truncated && terminals.length === result.terminals.length, - ...(result.visualLayouts - ? { - visualLayouts: result.visualLayouts.filter((layout) => worktreeIds.has(layout.worktreeId)) - } - : {}), - ...(result.topologyRevisions - ? { - topologyRevisions: Object.fromEntries( - Object.entries(result.topologyRevisions).filter(([worktreeId]) => - worktreeIds.has(worktreeId) - ) - ) - } - : {}), - ...(result.hostScope - ? { - hostScope: { - hostIds: result.hostScope.hostIds.filter((id) => id === hostId), - omittedHostIds: result.hostScope.omittedHostIds.filter((id) => id === hostId) - } - } - : {}) - } - return { kind: 'allowed', result: scoped } -} - -function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult { - return ( - typeof value === 'object' && - value !== null && - 'terminals' in value && - Array.isArray(value.terminals) && - 'truncated' in value && - typeof value.truncated === 'boolean' - ) -} - -function outsideHostMessage(targetId: string, subject: string): string { - return `The orca CLI on SSH host '${targetId}' can only reach that host's own terminals and the coordinator that dispatched them, and ${subject} is outside that. ${SSH_BRIDGE_REMOTE_CONTROL_HINT}` -} diff --git a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts deleted file mode 100644 index eca0f9227a5e..000000000000 --- a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { z } from 'zod' -import type { OrchestrationDb } from '../orchestration/db' -import type { OrcaRuntimeService } from '../orca-runtime' -import type { RpcResponse } from './core' -import { RpcDispatcher } from './dispatcher' -import { createOrchestrationRpcHarness } from './methods/orchestration/rpc-test-harness' -import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' -import { HOST_BOUND_SSH_BRIDGE_SCOPE as HOST_BOUND } from '../../ssh/ssh-bridge-caller-scope.test-fixture' - -const SentMessageResult = z.object({ message: z.object({ id: z.string() }) }) -const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const SIBLING_PANE = 'tab_sibling:cccccccc-cccc-4ccc-8ccc-cccccccccccc' -const LOCAL_PANE = 'tab_local:dddddddd-dddd-4ddd-8ddd-dddddddddddd' - -// A worker on the relay-route SSH host, a sibling on another host, and a local coordinator. -const HOSTS: Record = { - term_worker: 'ssh:box-1', - term_box_peer: 'ssh:box-1', - term_coord: 'local', - term_local: 'local', - term_sibling: 'ssh:box-2' -} - -describe('SSH bridge orchestration without the per-host opt-in', () => { - const harness = createOrchestrationRpcHarness() - let db: OrchestrationDb - let runtime: OrcaRuntimeService - - afterEach(() => harness.cleanup()) - - function setup(): void { - ;({ db, runtime } = harness.setup()) - const panes: Record = { - term_coord: harness.coordinatorPaneKey, - term_worker: WORKER_PANE, - term_sibling: SIBLING_PANE, - term_local: LOCAL_PANE - } - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) - vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( - (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null - ) - vi.spyOn(runtime, 'showTerminal').mockImplementation(async (handle) => { - const host = HOSTS[handle] - if (!host) { - throw new Error('terminal_not_found') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding reads only executionHostId. - return { handle, executionHostId: host } as Awaited< - ReturnType - > - }) - vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') - } - - function dispatchFromCoordinator(name: string, paneKey: string) { - const task = db.createTask({ spec: `${name} work` }) - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'terminal', handle: 'term_coord', paneKey: harness.coordinatorPaneKey }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: `term_${name}`, - paneKey, - processIncarnation: `runtime_test:term_${name}:1`, - worktreeId: `repo::${name}`, - effects: [], - setupState: 'not_applicable', - terminalOwnership: 'created' - }) - db.markWorkerDispatchReady(started.dispatch.id) - return { taskId: task.id, dispatchId: started.dispatch.id } - } - - function callAsBridge(method: string, params: Record): Promise { - return new RpcDispatcher({ runtime }).dispatch( - { - id: `req-${method}`, - authToken: 'unused', - method, - params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION - }, - { callerScope: HOST_BOUND } - ) - } - - function forbidden(response: RpcResponse): boolean { - return !response.ok && response.error.code === 'forbidden' - } - - it('lets a worker on the host report worker_done to the local coordinator that dispatched it', async () => { - setup() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - const response = await callAsBridge('orchestration.send', { - from: 'term_worker', - senderPaneKey: WORKER_PANE, - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ ...worker, outcome: 'succeeded' }) - }) - expect(response).toMatchObject({ ok: true, result: { lifecycle: { action: 'completed' } } }) - expect(db.getTask(worker.taskId)?.status).toBe('completed') - }) - - it('lets that worker check its own mailbox and message its coordinator by handle', async () => { - setup() - dispatchFromCoordinator('worker', WORKER_PANE) - expect((await callAsBridge('orchestration.check', { terminal: 'term_worker' })).ok).toBe(true) - expect((await callAsBridge('orchestration.inbox', { terminal: 'term_worker' })).ok).toBe(true) - const status = await callAsBridge('orchestration.send', { - from: 'term_worker', - to: 'term_coord', - subject: 'progress', - type: 'status' - }) - expect(forbidden(status)).toBe(false) - }) - - it.each<[string, string, Record]>([ - ['a sender on another host', 'orchestration.send', { from: 'term_sibling', subject: 'x' }], - ['a sender with no handle', 'orchestration.send', { subject: 'x' }], - [ - "a sibling's pane key", - 'orchestration.send', - { from: 'term_worker', senderPaneKey: SIBLING_PANE, subject: 'x' } - ], - [ - 'a local terminal that is not its coordinator', - 'orchestration.send', - { - from: 'term_worker', - to: 'term_local', - subject: 'x' - } - ], - ['a group address', 'orchestration.send', { from: 'term_worker', to: '@all', subject: 'x' }], - ["another host's mailbox", 'orchestration.check', { terminal: 'term_sibling' }], - ['an unscoped inbox', 'orchestration.inbox', {}], - [ - "a local terminal's question", - 'orchestration.ask', - { - from: 'term_local', - question: 'Proceed?' - } - ] - ])('refuses %s', async (_case, method, params) => { - setup() - dispatchFromCoordinator('worker', WORKER_PANE) - expect(forbidden(await callAsBridge(method, params))).toBe(true) - }) - - it("refuses a report that names another worker's Dispatch", async () => { - setup() - dispatchFromCoordinator('worker', WORKER_PANE) - const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) - const response = await callAsBridge('orchestration.send', { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ ...sibling, outcome: 'succeeded' }) - }) - expect(forbidden(response)).toBe(true) - expect(db.getTask(sibling.taskId)?.status).toBe('dispatched') - }) - - it('refuses a run the caller is no party to', async () => { - setup() - dispatchFromCoordinator('worker', WORKER_PANE) - const other = db.createRun({ - objective: 'Other', - coordinatorHandle: 'term_local', - coordinatorPaneKey: LOCAL_PANE - }) - const response = await callAsBridge('orchestration.check', { - terminal: 'term_box_peer', - run: other.id - }) - expect(forbidden(response)).toBe(true) - }) - - function callAsOwner(method: string, params: Record): Promise { - return new RpcDispatcher({ runtime }).dispatch({ - id: `owner-${method}`, - authToken: 'unused', - method, - params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION - }) - } - - function placeCoordinatorOnHost(): void { - vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => { - const host = handle === 'term_coord' ? 'ssh:box-1' : HOSTS[handle] - if (!host) { - throw new Error('terminal_not_found') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the binding reads only executionHostId. - return { handle, executionHostId: host } as Awaited< - ReturnType - > - }) - } - - it("lets a worker reply to coordinator mail routed to its own Dispatch's mailbox", async () => { - setup() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - const sent = await callAsOwner('orchestration.send', { - from: 'term_coord', - to: 'term_worker', - subject: 'Please confirm', - body: 'Continue?', - type: 'status' - }) - expect(sent).toMatchObject({ - ok: true, - result: { message: { to_handle: `dispatch:${worker.dispatchId}` } } - }) - const { message } = SentMessageResult.parse(sent.ok ? sent.result : null) - const reply = await callAsBridge('orchestration.reply', { - from: 'term_worker', - id: message.id, - body: 'Confirmed' - }) - expect(reply.ok).toBe(true) - }) - - it('lets an SSH coordinator answer a worker question stored in its own Run mailbox', async () => { - setup() - placeCoordinatorOnHost() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' - const { message, question } = db.createQuestion({ - runId, - dispatchId: worker.dispatchId, - askerHandle: 'term_worker', - question: 'May I continue?', - options: [] - }) - expect(message.to_handle).toBe(`run:${runId}`) - const reply = await callAsBridge('orchestration.reply', { - from: 'term_coord', - id: message.id, - body: 'Proceed' - }) - expect(reply.ok).toBe(true) - expect(db.getQuestion(question.message_id)?.status).toBe('answered') - }) - - it("lets a worker address its own Run's mailbox and its own Dispatch explicitly", async () => { - setup() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' - for (const to of [`run:${runId}`, `dispatch:${worker.dispatchId}`]) { - const sent = await callAsBridge('orchestration.send', { - from: 'term_worker', - to, - subject: 'p' - }) - expect(forbidden(sent)).toBe(false) - } - const asked = await callAsBridge('orchestration.ask', { - from: 'term_worker', - to: `run:${runId}`, - question: 'Proceed?', - timeoutMs: 1 - }) - expect(forbidden(asked)).toBe(false) - }) - - it('refuses a reply to mail addressed to another terminal, Dispatch, or Run', async () => { - setup() - dispatchFromCoordinator('worker', WORKER_PANE) - const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) - const other = db.createRun({ - objective: 'Other', - coordinatorHandle: 'term_local', - coordinatorPaneKey: LOCAL_PANE - }) - for (const to of ['term_local', `dispatch:${sibling.dispatchId}`, `run:${other.id}`]) { - const original = db.insertMessage({ from: 'term_coord', to, subject: 's', body: 's' }) - const reply = await callAsBridge('orchestration.reply', { - from: 'term_worker', - id: original.id, - body: 'forged' - }) - expect(forbidden(reply)).toBe(true) - expect(db.getMessageById(original.id)?.read).toBe(0) - } - }) - - it("refuses a worker reply to its Run's question, which only the coordinator owns", async () => { - setup() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - const runId = db.getDispatchContextById(worker.dispatchId)?.run_id ?? '' - const { message } = db.createQuestion({ - runId, - dispatchId: worker.dispatchId, - askerHandle: 'term_worker', - question: 'May I continue?', - options: [] - }) - const reply = await callAsBridge('orchestration.reply', { - from: 'term_worker', - id: message.id, - body: 'Proceed' - }) - expect(forbidden(reply)).toBe(true) - }) - - it("refuses explicit addresses for another worker's Dispatch or an unrelated Run", async () => { - setup() - dispatchFromCoordinator('worker', WORKER_PANE) - const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) - const other = db.createRun({ - objective: 'Other', - coordinatorHandle: 'term_local', - coordinatorPaneKey: LOCAL_PANE - }) - for (const to of [`dispatch:${sibling.dispatchId}`, `run:${other.id}`]) { - const sent = await callAsBridge('orchestration.send', { - from: 'term_worker', - to, - subject: 'p' - }) - expect(forbidden(sent)).toBe(true) - } - }) - - it('refuses a forged pane claiming the Dispatch mailbox', async () => { - setup() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - // A process on the worker's handle that does not hold the Dispatch's pane owns no Dispatch mail. - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => - handle === 'term_worker' - ? SIBLING_PANE - : handle === 'term_coord' - ? harness.coordinatorPaneKey - : null - ) - const original = db.insertMessage({ - from: 'term_coord', - to: `dispatch:${worker.dispatchId}`, - subject: 's', - body: 's' - }) - const reply = await callAsBridge('orchestration.reply', { - from: 'term_worker', - id: original.id, - body: 'forged' - }) - expect(forbidden(reply)).toBe(true) - }) - - it("lets an SSH coordinator address a same-host worker's Dispatch but not another host's", async () => { - setup() - placeCoordinatorOnHost() - const worker = dispatchFromCoordinator('worker', WORKER_PANE) - const sibling = dispatchFromCoordinator('sibling', SIBLING_PANE) - const send = (to: string) => - callAsBridge('orchestration.send', { from: 'term_coord', to, subject: 'p' }) - expect(forbidden(await send(`dispatch:${worker.dispatchId}`))).toBe(false) - expect(forbidden(await send(`dispatch:${sibling.dispatchId}`))).toBe(true) - }) -}) diff --git a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts b/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts deleted file mode 100644 index 3293abf33f20..000000000000 --- a/src/main/runtime/rpc/ssh-bridge-orchestration-binding.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Lets an SSH host's relayed `orca` CLI take part in orchestration as that host's own terminals. - * - * Without the per-target opt-in, the caller (`from` / `terminal`) must be a terminal on the bridged - * host. A party outside the host is reachable only as the coordinator of a Dispatch the caller works - * on, so a worker reports back to whoever dispatched it but cannot message or read anyone else. - * Routing stores mail under `run:`/`dispatch:` addresses, so ownership is resolved to those exact - * mailboxes the caller's live pane reads, never to a Run it merely shares. - */ -import { z } from 'zod' -import type { ExecutionHostId } from '../../../shared/execution-host' -import type { OrcaRuntimeService } from '../orca-runtime' -import type { OrchestrationDb } from '../orchestration/db' -import { - resolveTerminalOwnedMailboxes, - type TerminalOwnedMailboxes -} from './methods/orchestration/messaging/terminal-owned-mailboxes' - -const TERMINAL_CALLER_METHODS: ReadonlySet = new Set([ - 'orchestration.check', - 'orchestration.inbox' -]) - -export const SSH_BRIDGE_ORCHESTRATION_METHODS: readonly string[] = [ - ...TERMINAL_CALLER_METHODS, - 'orchestration.send', - 'orchestration.ask', - 'orchestration.reply' -] - -/** The refused subject, or null when the call stays inside the caller's own orchestration. */ -export async function findSshBridgeOrchestrationViolation( - runtime: OrcaRuntimeService, - hostId: ExecutionHostId, - methodName: string, - selectors: SshBridgeSelectors -): Promise { - const caller = selectors[TERMINAL_CALLER_METHODS.has(methodName) ? 'terminal' : 'from'] - if (!caller || !(await isHostTerminal(runtime, hostId, caller))) { - return `terminal '${caller ?? ''}'` - } - const paneKey = runtime.getTerminalPaneKey(caller) ?? undefined - const claimedPane = - selectors[methodName === 'orchestration.check' ? 'terminalPaneKey' : 'senderPaneKey'] - // Why: the pane key is the lifecycle identity, so it must be the caller's own, not a sibling's. - if (claimedPane && claimedPane !== paneKey) { - return `pane '${claimedPane}'` - } - const db = runtime.getOrchestrationDb() - const ownDispatch = db.getActiveDispatchForIdentity(caller, paneKey) - const run = selectors.run - if (run && ownDispatch?.run_id !== run && db.getRun(run)?.coordinator_handle !== caller) { - return `run '${run}'` - } - const owned = resolveTerminalOwnedMailboxes(runtime, db, caller) - if (methodName === 'orchestration.reply') { - const id = selectors.id ?? '' - const original = db.getMessageById(id) - return original && owned.addresses.has(original.to_handle) && (!run || run === original.run_id) - ? null - : `message '${id}'` - } - if (TERMINAL_CALLER_METHODS.has(methodName)) { - return null - } - const dispatchId = readPayloadDispatchId(selectors) - if (dispatchId) { - const named = db.getDispatchContextById(dispatchId) - const party = - named && - (named.assignee_handle === caller || - (paneKey !== undefined && named.assignee_pane_key === paneKey) || - named.creator_handle === caller) - if (!party) { - return `dispatch '${dispatchId}'` - } - } - const to = selectors.to - if ( - !to || - (ownDispatch?.creator_handle && to === ownDispatch.creator_handle) || - (await isOwnCanonicalRecipient(db, owned, runtime, hostId, to)) - ) { - return null - } - return (await isHostTerminal(runtime, hostId, to)) ? null : `recipient '${to}'` -} - -// A canonical address is in scope when it is the caller's own mailbox, the Run mailbox its held -// Dispatch reports to, or a same-host worker's Dispatch in the Run the caller coordinates. -async function isOwnCanonicalRecipient( - db: OrchestrationDb, - owned: TerminalOwnedMailboxes, - runtime: OrcaRuntimeService, - hostId: ExecutionHostId, - to: string -): Promise { - if (owned.addresses.has(to) || (owned.dispatch && to === `run:${owned.dispatch.run_id}`)) { - return true - } - if (!to.startsWith('dispatch:') || owned.runId === undefined) { - return false - } - const dispatch = db.getDispatchContextById(to.slice('dispatch:'.length)) - return ( - dispatch?.run_id === owned.runId && - dispatch.assignee_handle !== null && - (await isHostTerminal(runtime, hostId, dispatch.assignee_handle)) - ) -} - -export async function isHostTerminal( - runtime: OrcaRuntimeService, - hostId: ExecutionHostId, - handle: string -): Promise { - return (await resolveTerminalHost(runtime, handle)) === hostId -} - -async function resolveTerminalHost( - runtime: OrcaRuntimeService, - handle: string -): Promise { - try { - return (await runtime.showTerminal(handle)).executionHostId ?? null - } catch { - return null - } -} - -function readPayloadDispatchId(selectors: SshBridgeSelectors): string | null { - if (!selectors.payload) { - return null - } - try { - return parseSshBridgeSelectors(JSON.parse(selectors.payload)).dispatchId ?? null - } catch { - // Why: the handler owns malformed-payload errors; nothing here can name a Dispatch. - return null - } -} - -// Why: a missing, non-string or empty selector names nothing, and the handler owns rejecting it. -const BridgeSelector = z.string().min(1).optional().catch(undefined) - -/** The selector fields the bridge binds to the caller's host; anything else names nothing. */ -const SshBridgeCallSelectors = z - .object({ - terminal: BridgeSelector, - terminalPaneKey: BridgeSelector, - senderPaneKey: BridgeSelector, - from: BridgeSelector, - to: BridgeSelector, - run: BridgeSelector, - id: BridgeSelector, - payload: BridgeSelector, - dispatchId: BridgeSelector, - worktree: BridgeSelector - }) - .catch({}) - -export type SshBridgeSelectors = z.infer - -export function parseSshBridgeSelectors(params: unknown): SshBridgeSelectors { - return SshBridgeCallSelectors.parse(params) -} diff --git a/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts b/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts deleted file mode 100644 index 79e67e708e04..000000000000 --- a/src/main/runtime/runtime-rpc-ssh-bridge-credential.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import { sshBridgeCredentials } from './rpc/ssh-bridge-credentials' -import type { RpcResponse } from './rpc/core' - -const BRIDGE_SCOPE = { kind: 'ssh-bridge', targetId: 'box-1', remoteCliControl: false } as const - -function createServer(): OrcaRuntimeRpcServer { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-bridge-')) - return new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: false - }) -} - -async function send( - server: OrcaRuntimeRpcServer, - authToken: string, - method: string, - params: unknown = {} -): Promise { - return server['handleMessage'](JSON.stringify({ id: `req-${method}`, authToken, method, params })) -} - -function errorCode(response: RpcResponse): string | null { - return response.ok ? null : response.error.code -} - -describe('the runtime socket and SSH bridge credentials', () => { - it('scopes a bridge credential to its SSH target instead of owner authority', async () => { - const server = createServer() - const credential = sshBridgeCredentials.mint(BRIDGE_SCOPE) - try { - expect(errorCode(await send(server, credential.token, 'computer.click'))).toBe('forbidden') - expect(errorCode(await send(server, credential.token, 'accounts.selectClaude'))).toBe( - 'forbidden' - ) - expect(errorCode(await send(server, credential.token, 'skills.install'))).toBe('forbidden') - expect((await send(server, credential.token, 'status.get')).ok).toBe(true) - } finally { - credential.revoke() - } - }) - - it('rejects a credential once its invocation settled', async () => { - const server = createServer() - const credential = sshBridgeCredentials.mint(BRIDGE_SCOPE) - credential.revoke() - expect(errorCode(await send(server, credential.token, 'status.get'))).toBe('unauthorized') - }) - - it('keeps the owner token unscoped', async () => { - const server = createServer() - const response = await send(server, server['authToken'], 'computer.click') - expect(errorCode(response)).not.toBe('forbidden') - }) -}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts index ad1bd49cbad5..f3223a3ba429 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts @@ -6,7 +6,6 @@ import { errorResponse } from '../rpc/errors' import { RuntimeRpcBinaryRouting } from './runtime-rpc-binary-routing' import { classifyRuntimeLongPoll, type RuntimeLongPollClass } from './runtime-rpc-long-poll' import { OWNER_RPC_CALLER_SCOPE, type RpcCallerScope } from '../rpc/rpc-caller-scope' -import { sshBridgeCredentials } from '../rpc/ssh-bridge-credentials' export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { // Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1. @@ -136,11 +135,6 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { if (request.authToken === this.authToken) { return { request, callerScope: OWNER_RPC_CALLER_SCOPE } } - // Why: a bridged SSH CLI holds only its invocation's credential, scoped to that SSH target. - const bridgeScope = sshBridgeCredentials.resolve(request.authToken) - if (bridgeScope) { - return { request, callerScope: bridgeScope } - } return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } } diff --git a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts b/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts deleted file mode 100644 index 53a6f9c652ac..000000000000 --- a/src/main/ssh/ssh-bridge-caller-scope.test-fixture.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SshBridgeCallerScope } from '../runtime/rpc/ssh-bridge-credentials' - -/** A bridged SSH CLI whose host the user opted in to control Orca, i.e. the pre-scope behaviour. */ -export const CONTROL_GRANTED_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { - kind: 'ssh-bridge', - targetId: 'box-1', - remoteCliControl: true -} - -/** The default bridge: reaches only that host's own terminals. */ -export const HOST_BOUND_SSH_BRIDGE_SCOPE: SshBridgeCallerScope = { - kind: 'ssh-bridge', - targetId: 'box-1', - remoteCliControl: false -} diff --git a/src/shared/ssh-bridge-credential-env.ts b/src/shared/ssh-bridge-credential-env.ts deleted file mode 100644 index 72985d339cd3..000000000000 --- a/src/shared/ssh-bridge-credential-env.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Env var carrying the per-invocation credential a bridged SSH CLI presents instead of the owner token. - * - * Its presence also puts the CLI in bridge mode: no local-only commands and no paired-server routing, - * because those act with this machine's own authority rather than through the scoped runtime socket. - */ -export const ORCA_SSH_BRIDGE_CREDENTIAL_ENV = 'ORCA_SSH_BRIDGE_CREDENTIAL' - -export function readSshBridgeCredential(env: NodeJS.ProcessEnv = process.env): string | null { - const value = env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] - return typeof value === 'string' && value.length > 0 ? value : null -} From 04187731069ddc2fdb35f0b0ccc1cf102ba69f92 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:20:11 -0700 Subject: [PATCH 46/58] feat(daemon): pause automatic restores after a same-boot crash (#27433) * feat(daemon): pause automatic restores after a same-boot crash * fix(daemon): per-version crash marker, owner-only writes, orderly login retirement, no Windows boot-time guess --------- Co-authored-by: m4air --- .../automatic-work-run-marker-store.test.ts | 94 ++++++++++++ .../daemon/automatic-work-run-marker-store.ts | 135 ++++++++++++++++++ src/main/daemon/daemon-entry.ts | 1 + src/main/daemon/daemon-main.ts | 14 +- src/main/daemon/daemon-server-options.ts | 2 + src/main/daemon/daemon-server.ts | 19 ++- src/main/daemon/terminal-spawn-gate.test.ts | 33 +++++ src/main/daemon/terminal-spawn-gate.ts | 5 + src/shared/automatic-work-run-marker.test.ts | 84 +++++++++++ src/shared/automatic-work-run-marker.ts | 79 ++++++++++ 10 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 src/main/daemon/automatic-work-run-marker-store.test.ts create mode 100644 src/main/daemon/automatic-work-run-marker-store.ts create mode 100644 src/shared/automatic-work-run-marker.test.ts create mode 100644 src/shared/automatic-work-run-marker.ts diff --git a/src/main/daemon/automatic-work-run-marker-store.test.ts b/src/main/daemon/automatic-work-run-marker-store.test.ts new file mode 100644 index 000000000000..df3a5bce0fc2 --- /dev/null +++ b/src/main/daemon/automatic-work-run-marker-store.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AutomaticWorkRunMarker, + automaticWorkMarkerPathFor +} from './automatic-work-run-marker-store' + +const BOOT = { bootTimeMs: 1_000_000, bootId: null } + +describe('AutomaticWorkRunMarker', () => { + let dir: string + let path: string + const logger = { log: vi.fn() } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-automatic-work-')) + path = join(dir, 'automatic-work-run.json') + logger.log.mockClear() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function open(now = 10, boot = BOOT): AutomaticWorkRunMarker { + const marker = new AutomaticWorkRunMarker(path, logger) + marker.open(now, boot) + return marker + } + + it('pauses after the previous daemon died with automatic work running in this boot', () => { + open().arm() + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ state: 'running' }) + + const next = open(20) + expect(next.mode).toEqual({ kind: 'paused', reason: 'unclean_host_death', since: 20 }) + expect(logger.log).toHaveBeenCalledWith( + 'automatic-work-paused', + expect.objectContaining({ reason: 'unclean_host_death' }) + ) + }) + + it('does not pause after an orderly exit, or when nothing automatic ran', () => { + const first = open() + first.arm() + first.markCleanExit() + expect(open(20).mode).toEqual({ kind: 'normal' }) + expect(open(30).mode).toEqual({ kind: 'normal' }) + }) + + it('does not pause on a new boot', () => { + open().arm() + expect(open(20, { bootTimeMs: BOOT.bootTimeMs + 3_600_000, bootId: null }).mode).toEqual({ + kind: 'normal' + }) + }) + + it('arms on open when a fork was admitted before the endpoint was won', () => { + const marker = new AutomaticWorkRunMarker(path, logger) + marker.arm() + marker.open(10, BOOT) + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ state: 'running' }) + }) + + it('stays inert without a path and before open', () => { + const inert = new AutomaticWorkRunMarker(undefined, logger) + inert.open(10, BOOT) + inert.arm() + inert.markCleanExit() + expect(inert.mode).toEqual({ kind: 'normal' }) + + const unopened = new AutomaticWorkRunMarker(path, logger) + unopened.markCleanExit() + expect(() => readFileSync(path)).toThrow() + }) + + it('never writes once the daemon has lost its endpoint to a successor', () => { + let owns = true + const marker = new AutomaticWorkRunMarker(path, logger, () => owns) + marker.open(10, BOOT) + marker.arm() + owns = false + marker.markCleanExit() + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ state: 'running' }) + }) + + it('keeps one marker per protocol version beside its PID record', () => { + expect(automaticWorkMarkerPathFor(join(dir, 'daemon-v44.pid'))).toBe( + join(dir, 'daemon-v44.automatic-work-run.json') + ) + }) +}) diff --git a/src/main/daemon/automatic-work-run-marker-store.ts b/src/main/daemon/automatic-work-run-marker-store.ts new file mode 100644 index 000000000000..4abcbe94b00b --- /dev/null +++ b/src/main/daemon/automatic-work-run-marker-store.ts @@ -0,0 +1,135 @@ +import { readFileSync } from 'node:fs' +import { uptime } from 'node:os' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import type { AutomaticWorkMode } from '../../shared/host-spawn-admission' +import { + decideStartupAutomaticWorkMode, + parseAutomaticWorkRunMarker, + type AutomaticWorkRunMarker as AutomaticWorkRunMarkerFile, + type BootIdentity +} from '../../shared/automatic-work-run-marker' + +const PRIVATE_FILE_MODE = 0o600 + +// Why per protocol version: an old-protocol daemon can stay alive beside a new one in the +// same runtime dir, and must neither read the other's run as a crash nor overwrite it. +export function automaticWorkMarkerPathFor(pidRecordPath: string): string { + return `${pidRecordPath.replace(/\.pid$/, '')}.automatic-work-run.json` +} + +export function readBootIdentity(now: number = Date.now()): BootIdentity { + let bootId: string | null = null + if (process.platform === 'linux') { + try { + bootId = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || null + } catch { + bootId = null + } + } + // Why null on Windows: Fast Startup keeps the uptime counter across a shutdown, so boot + // time cannot tell a new boot from the same one; an unknown boot never pauses. + return { bootTimeMs: process.platform === 'win32' ? null : now - uptime() * 1000, bootId } +} + +function readPreviousMarker(path: string): AutomaticWorkRunMarkerFile | null { + try { + return parseAutomaticWorkRunMarker(readFileSync(path, 'utf8')) + } catch { + return null + } +} + +/** + * The daemon's record of whether automatic restores were running when it last died. + * Inert until `open()`, which the endpoint owner calls once it has won the endpoint. + * Fail-open: a marker that cannot be read or written never blocks a restore. + */ +export class AutomaticWorkRunMarker { + private currentMode: AutomaticWorkMode = { kind: 'normal' } + private opened = false + private armRequested = false + private armed = false + private boot: BootIdentity | null = null + + constructor( + private readonly path: string | undefined, + private readonly logger: { log: (event: string, details: Record) => void }, + /** Why: a daemon that lost its endpoint must not rewrite its successor's record. */ + private readonly ownsEndpoint: () => boolean = () => true + ) {} + + open(now: number = Date.now(), boot: BootIdentity = readBootIdentity(now)): void { + if (this.opened || !this.path) { + return + } + this.opened = true + this.boot = boot + const previous = readPreviousMarker(this.path) + this.currentMode = decideStartupAutomaticWorkMode(previous, boot, now) + if (this.currentMode.kind === 'paused') { + this.log('automatic-work-paused', { + reason: this.currentMode.reason, + carriedOver: previous?.mode.kind === 'paused' + }) + } + this.writeSync(this.armRequested ? 'running' : 'clean-exit') + this.armed = this.armRequested + } + + get mode(): AutomaticWorkMode { + return this.currentMode + } + + gateOptions(): { automaticWorkMode: () => AutomaticWorkMode; armAutomaticWork: () => void } { + return { automaticWorkMode: () => this.currentMode, armAutomaticWork: () => this.arm() } + } + + /** Durably records `running` before the first automatic fork of this run. + * Why sync: an async write could land after the clean-exit write and fake a crash. */ + arm(): void { + this.armRequested = true + if (!this.opened || this.armed) { + return + } + this.armed = true + this.writeSync('running') + } + + markCleanExit(): void { + if (this.opened) { + this.writeSync('clean-exit') + } + } + + private writeSync(state: AutomaticWorkRunMarkerFile['state']): void { + if (!this.path || !this.boot || !this.ownsEndpoint()) { + return + } + const marker: AutomaticWorkRunMarkerFile = { + schemaVersion: 1, + boot: this.boot, + state, + mode: this.currentMode + } + try { + writeFileDurableSync( + durableWriteTempPath(this.path), + this.path, + JSON.stringify(marker), + PRIVATE_FILE_MODE + ) + } catch (error) { + this.log('automatic-work-marker-write-failed', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + + private log(event: string, details: Record): void { + try { + this.logger.log(event, details) + } catch { + // Diagnostics never block terminal work. + } + } +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 8d409a9dc871..4a951edd3c62 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -260,6 +260,7 @@ async function main(): Promise { onRetire: (details) => { shuttingDown = true daemonLog.log('login-session-dead-retire', details) + daemon?.recordOrderlyExit() daemonLog.close() // Why: crash-style exit (no PTY teardown) keeps session meta unclean so the // replacement daemon cold-restores scrollback; stale socket/pid files ride diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index add2bc7ad808..45a86c689510 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -2,6 +2,7 @@ import { DaemonServer } from './daemon-server' import type { DaemonServerOptions } from './daemon-server-options' import type { DaemonFileLog } from './daemon-file-log' import { readTerminalSpawnAdmissionArg } from './terminal-spawn-gate' +import { automaticWorkMarkerPathFor } from './automatic-work-run-marker-store' export type DaemonStartOptions = { socketPath: string @@ -21,6 +22,7 @@ export type DaemonStartOptions = { onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair'] log?: DaemonFileLog spawnAdmission?: DaemonServerOptions['spawnAdmission'] + automaticWorkMarkerPath?: string onIdleShutdown?: () => void onRpcShutdown?: () => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] @@ -28,6 +30,8 @@ export type DaemonStartOptions = { export type DaemonHandle = { shutdown(): Promise + /** Marks this run as ended on purpose when the process exits without a teardown. */ + recordOrderlyExit(): void } export async function startDaemon(opts: DaemonStartOptions): Promise { @@ -53,6 +57,13 @@ export async function startDaemon(opts: DaemonStartOptions): Promise server.shutdown() + shutdown: () => server.shutdown(), + recordOrderlyExit: () => server.recordOrderlyExit() } } diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts index ed849a73e9d6..5cb787f1e3df 100644 --- a/src/main/daemon/daemon-server-options.ts +++ b/src/main/daemon/daemon-server-options.ts @@ -30,6 +30,8 @@ export type DaemonServerOptions = { log?: DaemonFileLog /** Headless hosts enforce; desktop only observes until the client handles deferral. */ spawnAdmission?: TerminalSpawnAdmissionMode + /** Where the daemon remembers whether automatic restores were running when it died. */ + automaticWorkMarkerPath?: string spawnSubprocess: (opts: { sessionId: string cols: number diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 44288a266b62..7fdc04e8b41f 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -22,6 +22,7 @@ import { checkPtySpawnHealth } from './pty-subprocess' import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import { TerminalHost } from './terminal-host' import { TerminalSpawnGate } from './terminal-spawn-gate' +import { AutomaticWorkRunMarker } from './automatic-work-run-marker-store' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -34,6 +35,7 @@ export class DaemonServer { private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 private readonly log: DaemonFileLog + private readonly automaticWork: AutomaticWorkRunMarker private readonly host: TerminalHost private readonly transientFactRelay: BackgroundTransientFactRelay private readonly streamDataBatcher: DaemonStreamDataBatcher @@ -46,14 +48,21 @@ export class DaemonServer { private readonly admission: DaemonTerminalAdmission private readonly requestRouter: DaemonRequestRouter private stopStreamBacklogProbe: () => void = () => {} + readonly recordOrderlyExit = (): void => this.automaticWork.markCleanExit() constructor(options: DaemonServerOptions) { this.log = options.log ?? createNoopDaemonFileLog() + this.automaticWork = new AutomaticWorkRunMarker( + options.automaticWorkMarkerPath, + this.log, + () => !this.endpoint.lost + ) this.host = new TerminalHost({ spawnSubprocess: options.spawnSubprocess, spawnGate: new TerminalSpawnGate({ log: (event, details) => this.log.log(event, details), - mode: options.spawnAdmission ?? 'observe' + mode: options.spawnAdmission ?? 'observe', + ...this.automaticWork.gateOptions() }), reportReadinessEvent: (event, details) => this.log.log(event, details), onSessionReaped: (sessionId) => { @@ -235,8 +244,10 @@ export class DaemonServer { })) } - start(): Promise { - return this.lifecycle.start((socket) => this.connections.accept(socket)) + async start(): Promise { + await this.lifecycle.start((socket) => this.connections.accept(socket)) + // Why after start: only the daemon that won the endpoint may judge or rewrite the marker. + this.automaticWork.open() } shutdown(): Promise { @@ -267,6 +278,8 @@ export class DaemonServer { } this.streamDataBatcher.clear() this.historySeedTransfers.dispose() + // Why after host dispose: every automatic session is gone, so this exit was orderly. + this.automaticWork.markCleanExit() // Let canceled in-flight RPCs enqueue their protocol errors before transport destruction. await waitForImmediate() this.connections.dispose() diff --git a/src/main/daemon/terminal-spawn-gate.test.ts b/src/main/daemon/terminal-spawn-gate.test.ts index f049df8d72bc..2e0f518f743a 100644 --- a/src/main/daemon/terminal-spawn-gate.test.ts +++ b/src/main/daemon/terminal-spawn-gate.test.ts @@ -234,6 +234,39 @@ describe('TerminalSpawnGate', () => { ).resolves.toMatchObject({ isNew: true }) }) + it('holds declared automatic restores while paused, but never user resumes or old callers', async () => { + gate = new TerminalSpawnGate({ + log, + mode: 'enforce', + now: () => Date.now(), + automaticWorkMode: () => ({ kind: 'paused', reason: 'unclean_host_death', since: 0 }) + }) + host = new TerminalHost({ spawnSubprocess, spawnGate: gate }) + await expect(restore('declared', { declaredIntent: 'automatic-restore' })).rejects.toThrow( + 'terminal_spawn_deferred:paused' + ) + await expect(restore('opened', { declaredIntent: 'user-resume' })).resolves.toMatchObject({ + isNew: true + }) + await expect(restore('legacy')).resolves.toMatchObject({ isNew: true }) + expect(spawnSubprocess).toHaveBeenCalledTimes(2) + }) + + it('arms the crash marker before the first automatic fork only', async () => { + const armAutomaticWork = vi.fn() + gate = new TerminalSpawnGate({ + log, + mode: 'enforce', + now: () => Date.now(), + armAutomaticWork + }) + host = new TerminalHost({ spawnSubprocess, spawnGate: gate }) + await host.createOrAttach({ sessionId: 'shell', cols: 80, rows: 24, streamClient: stream() }) + expect(armAutomaticWork).not.toHaveBeenCalled() + await restore('first') + expect(armAutomaticWork).toHaveBeenCalledOnce() + }) + it('never queues an interactive shell behind a restore storm', async () => { for (let i = 0; i < 3; i++) { await restore(`burst-${i}`) diff --git a/src/main/daemon/terminal-spawn-gate.ts b/src/main/daemon/terminal-spawn-gate.ts index 8c72b4f12277..dc8de3a9a1e2 100644 --- a/src/main/daemon/terminal-spawn-gate.ts +++ b/src/main/daemon/terminal-spawn-gate.ts @@ -60,6 +60,8 @@ type TerminalSpawnGateOptions = { now?: () => number pressure?: () => HostPressureLevel automaticWorkMode?: () => AutomaticWorkMode + /** Runs synchronously before the first automatic fork is admitted. */ + armAutomaticWork?: () => void } /** @@ -167,6 +169,9 @@ export class TerminalSpawnGate { } private lease(facts: SpawnRequestFacts, charge: SpawnCharge, now: number): TerminalSpawnLease { + if (charge === 'automatic') { + this.options.armAutomaticWork?.() + } applySpawnAdmission(this.state, facts, charge, this.limits, now) let settled = false const settle = (sessionId: string | null, isAlive: boolean): void => { diff --git a/src/shared/automatic-work-run-marker.test.ts b/src/shared/automatic-work-run-marker.test.ts new file mode 100644 index 000000000000..8973e6b9f4a5 --- /dev/null +++ b/src/shared/automatic-work-run-marker.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + decideStartupAutomaticWorkMode, + isSameBoot, + parseAutomaticWorkRunMarker, + type AutomaticWorkRunMarker +} from './automatic-work-run-marker' + +const BOOT = { bootTimeMs: 1_000_000, bootId: null } + +function marker(overrides: Partial = {}): AutomaticWorkRunMarker { + return { schemaVersion: 1, boot: BOOT, state: 'running', mode: { kind: 'normal' }, ...overrides } +} + +describe('decideStartupAutomaticWorkMode', () => { + it('pauses only after a death with automatic work running during the same boot', () => { + expect(decideStartupAutomaticWorkMode(marker(), BOOT, 5)).toEqual({ + kind: 'paused', + reason: 'unclean_host_death', + since: 5 + }) + expect(decideStartupAutomaticWorkMode(marker({ state: 'clean-exit' }), BOOT, 5)).toEqual({ + kind: 'normal' + }) + expect(decideStartupAutomaticWorkMode(null, BOOT, 5)).toEqual({ kind: 'normal' }) + }) + + it('lets a new boot through the normal budget instead of pausing', () => { + expect( + decideStartupAutomaticWorkMode( + marker(), + { bootTimeMs: BOOT.bootTimeMs + 3_600_000, bootId: null }, + 5 + ) + ).toEqual({ kind: 'normal' }) + expect( + decideStartupAutomaticWorkMode( + marker({ boot: { bootTimeMs: 0, bootId: 'a' } }), + { bootTimeMs: 0, bootId: 'b' }, + 5 + ) + ).toEqual({ kind: 'normal' }) + }) + + it('keeps a recorded pause until an explicit resume, across boots', () => { + const paused = { kind: 'paused' as const, reason: 'unclean_host_death' as const, since: 1 } + expect( + decideStartupAutomaticWorkMode( + marker({ state: 'clean-exit', mode: paused }), + { bootTimeMs: 9e12, bootId: null }, + 5 + ) + ).toEqual(paused) + }) +}) + +describe('isSameBoot', () => { + it('prefers an exact boot id and tolerates wall-clock drift otherwise', () => { + expect(isSameBoot({ bootTimeMs: 0, bootId: 'x' }, { bootTimeMs: 9e9, bootId: 'x' })).toBe(true) + expect(isSameBoot({ bootTimeMs: 0, bootId: null }, { bootTimeMs: 60_000, bootId: null })).toBe( + true + ) + expect(isSameBoot({ bootTimeMs: 0, bootId: null }, { bootTimeMs: 600_000, bootId: null })).toBe( + false + ) + // Windows: boot time cannot identify a boot, so it never reads as the same one. + expect(isSameBoot({ bootTimeMs: null, bootId: null }, { bootTimeMs: null, bootId: null })).toBe( + false + ) + }) +}) + +describe('parseAutomaticWorkRunMarker', () => { + it('round-trips a marker and reads foreign content as no previous run', () => { + expect(parseAutomaticWorkRunMarker(JSON.stringify(marker()))).toEqual(marker()) + expect(parseAutomaticWorkRunMarker('not json')).toBeNull() + expect( + parseAutomaticWorkRunMarker(JSON.stringify({ ...marker(), schemaVersion: 2 })) + ).toBeNull() + expect( + parseAutomaticWorkRunMarker(JSON.stringify({ ...marker(), mode: { kind: 'paused' } })) + ).toBeNull() + }) +}) diff --git a/src/shared/automatic-work-run-marker.ts b/src/shared/automatic-work-run-marker.ts new file mode 100644 index 000000000000..1a74bf8d6f01 --- /dev/null +++ b/src/shared/automatic-work-run-marker.ts @@ -0,0 +1,79 @@ +// Whether automatic restores may run after the terminal host starts, from what its +// previous run left behind. Pure; the host owns the file and the clock. +import { z } from 'zod' +import type { AutomaticWorkMode } from './host-spawn-admission' + +export type BootIdentity = { + /** Wall-clock boot time, compared with a tolerance; null where it cannot identify a boot. */ + bootTimeMs: number | null + /** Exact per-boot id where the OS exposes one (Linux). */ + bootId: string | null +} + +export type AutomaticWorkRunMarker = { + schemaVersion: 1 + boot: BootIdentity + /** `running`: automatic work was admitted and the host has not exited cleanly since. */ + state: 'running' | 'clean-exit' + mode: AutomaticWorkMode +} + +const BOOT_TIME_TOLERANCE_MS = 120_000 + +export function isSameBoot(a: BootIdentity, b: BootIdentity): boolean { + if (a.bootId !== null && b.bootId !== null) { + return a.bootId === b.bootId + } + if (a.bootTimeMs === null || b.bootTimeMs === null) { + return false + } + return Math.abs(a.bootTimeMs - b.bootTimeMs) <= BOOT_TIME_TOLERANCE_MS +} + +/** + * A host that died with automatic work running, while the machine stayed up, pauses + * automatic restores until someone resumes them; a new boot goes through the normal + * budget instead. A pause already on record survives until an explicit resume. + */ +export function decideStartupAutomaticWorkMode( + previous: AutomaticWorkRunMarker | null, + current: BootIdentity, + now: number +): AutomaticWorkMode { + if (!previous) { + return { kind: 'normal' } + } + if (previous.mode.kind === 'paused') { + return previous.mode + } + if (previous.state === 'running' && isSameBoot(previous.boot, current)) { + return { kind: 'paused', reason: 'unclean_host_death', since: now } + } + return { kind: 'normal' } +} + +const MarkerSchema = z.object({ + schemaVersion: z.literal(1), + boot: z.object({ bootTimeMs: z.number().finite().nullable(), bootId: z.string().nullable() }), + state: z.enum(['running', 'clean-exit']), + mode: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('normal') }), + z.object({ + kind: z.literal('paused'), + reason: z.enum(['unclean_host_death', 'pressure', 'operator']), + since: z.number() + }) + ]) +}) + +/** Unreadable or foreign content reads as "no previous run", never as a pause. */ +export function parseAutomaticWorkRunMarker(text: string): AutomaticWorkRunMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return null + } + const result = MarkerSchema.safeParse(parsed) + return result.success ? result.data : null +} From 398cb846ff16a5abfb9350309842c16a3fe8148a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:20:15 -0700 Subject: [PATCH 47/58] fix(serve): report secrets unavailable instead of hanging on an unreachable macOS keychain (#27441) * fix(serve): report secrets unavailable instead of hanging on an unreachable macOS keychain * fix(serve): classify keychain probe by exit code, skip under a mock keychain, ratchet direct safeStorage imports --------- Co-authored-by: m4air --- .../encrypted-api-key-file-store.ts | 2 +- src/main/host/electron-secret-store.ts | 11 +++- .../host/macos-keychain-reachability.test.ts | 61 +++++++++++++++++ src/main/host/macos-keychain-reachability.ts | 65 +++++++++++++++++++ .../host/safe-storage-availability.test.ts | 51 +++++++++++++++ src/main/host/safe-storage-availability.ts | 49 ++++++++++++++ .../host/safe-storage-import-ratchet.test.ts | 31 +++++++++ src/main/host/serve-keychain-guard.test.ts | 49 ++++++++++++++ src/main/host/serve-keychain-guard.ts | 36 ++++++++++ src/main/minimax/minimax-cookie-store.ts | 2 +- .../profile-cloud-session-store.ts | 2 +- src/main/orcad/orcad-entry.ts | 4 +- .../orcad/orcad-secret-protection-gap.test.ts | 13 ++++ src/main/orcad/orcad-secret-protection-gap.ts | 11 ++++ src/main/plugins/plugin-secrets-store.ts | 2 +- .../startup/main-process-ready-foundation.ts | 7 ++ src/main/zcode/zcode-plan-api-key-store.ts | 2 +- 17 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 src/main/host/macos-keychain-reachability.test.ts create mode 100644 src/main/host/macos-keychain-reachability.ts create mode 100644 src/main/host/safe-storage-availability.test.ts create mode 100644 src/main/host/safe-storage-availability.ts create mode 100644 src/main/host/safe-storage-import-ratchet.test.ts create mode 100644 src/main/host/serve-keychain-guard.test.ts create mode 100644 src/main/host/serve-keychain-guard.ts create mode 100644 src/main/orcad/orcad-secret-protection-gap.test.ts create mode 100644 src/main/orcad/orcad-secret-protection-gap.ts diff --git a/src/main/credentials/encrypted-api-key-file-store.ts b/src/main/credentials/encrypted-api-key-file-store.ts index 8d6e9f68f7b3..0d5999e03c63 100644 --- a/src/main/credentials/encrypted-api-key-file-store.ts +++ b/src/main/credentials/encrypted-api-key-file-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability' import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts index c0804667e5a5..bf9961a9f933 100644 --- a/src/main/host/electron-secret-store.ts +++ b/src/main/host/electron-secret-store.ts @@ -1,4 +1,5 @@ import { safeStorage } from 'electron' +import { guardedSafeStorage, safeStorageWithheldReason } from './safe-storage-availability' import type { SecretStore } from '../../shared/secret-store' /** @@ -7,18 +8,22 @@ import type { SecretStore } from '../../shared/secret-store' */ export class ElectronSecretStore implements SecretStore { isEncryptionAvailable(): boolean { - return safeStorage.isEncryptionAvailable() + return guardedSafeStorage.isEncryptionAvailable() } encryptString(plainText: string): Buffer { - return safeStorage.encryptString(plainText) + return guardedSafeStorage.encryptString(plainText) } decryptString(cipher: Buffer): string { - return safeStorage.decryptString(cipher) + return guardedSafeStorage.decryptString(cipher) } describeProtectionGap(): string | null { + const withheld = safeStorageWithheldReason() + if (withheld !== null) { + return `The OS keychain is unavailable for this run (${withheld}), so secrets are stored unencrypted. Start orca serve from a logged-in desktop session, or unlock the login keychain, to seal them.` + } // Why availability first: it is the call that actually probes the keyring, so every // backend read below is free and cannot change which call blocks. if (!safeStorage.isEncryptionAvailable()) { diff --git a/src/main/host/macos-keychain-reachability.test.ts b/src/main/host/macos-keychain-reachability.test.ts new file mode 100644 index 000000000000..620f166dc811 --- /dev/null +++ b/src/main/host/macos-keychain-reachability.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { probeMacosKeychainReachability } from './macos-keychain-reachability' + +type RunResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string + timedOut: boolean +} + +function runner(result: Partial | Error) { + return async (): Promise => { + if (result instanceof Error) { + throw result + } + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...result } + } +} + +describe('probeMacosKeychainReachability', () => { + it('reads each security(1) outcome into a verdict', async () => { + await expect(probeMacosKeychainReachability(runner({ code: 0 }))).resolves.toEqual({ + kind: 'reachable' + }) + await expect( + probeMacosKeychainReachability( + runner({ + code: 37, + stderr: 'security: SecKeychainCopySettings : A default keychain could not be found.' + }) + ) + ).resolves.toEqual({ kind: 'unavailable', reason: 'no_default_keychain' }) + await expect( + probeMacosKeychainReachability( + runner({ code: 36, stderr: 'security: User interaction is not allowed.' }) + ) + ).resolves.toEqual({ kind: 'unavailable', reason: 'interaction_not_allowed' }) + await expect( + probeMacosKeychainReachability(runner({ code: null, timedOut: true })) + ).resolves.toEqual({ kind: 'unavailable', reason: 'timeout' }) + }) + + it('classifies by exit code when the message is localized', async () => { + await expect( + probeMacosKeychainReachability(runner({ code: 37, stderr: 'trousseau introuvable' })) + ).resolves.toEqual({ kind: 'unavailable', reason: 'no_default_keychain' }) + await expect( + probeMacosKeychainReachability(runner({ code: 36, stderr: 'interaction refusée' })) + ).resolves.toEqual({ kind: 'unavailable', reason: 'interaction_not_allowed' }) + }) + + it('keeps today’s behavior when it cannot tell', async () => { + await expect( + probeMacosKeychainReachability(runner({ code: 1, stderr: 'something else' })) + ).resolves.toEqual({ kind: 'unknown' }) + await expect(probeMacosKeychainReachability(runner(new Error('ENOENT')))).resolves.toEqual({ + kind: 'unknown' + }) + }) +}) diff --git a/src/main/host/macos-keychain-reachability.ts b/src/main/host/macos-keychain-reachability.ts new file mode 100644 index 000000000000..3c9cf5d621a6 --- /dev/null +++ b/src/main/host/macos-keychain-reachability.ts @@ -0,0 +1,65 @@ +import { runProcess } from '@orca/process-host' + +export type KeychainReachability = + | { kind: 'reachable' } + | { kind: 'unavailable'; reason: 'no_default_keychain' | 'interaction_not_allowed' | 'timeout' } + /** The probe could not tell; callers keep today's behavior. */ + | { kind: 'unknown' } + +const PROBE_TIMEOUT_MS = 5_000 + +/** + * Ask the macOS keychain, out of process, whether this login has a usable default keychain. + * + * Why out of process: the in-process question is `safeStorage`, which is synchronous and + * can wait forever on an authorization prompt that a session without a GUI cannot show + * (#25453). A child can be killed on a timeout. + * + * Why `show-keychain-info`: it reads settings of the default keychain and never reads a + * secret, so it cannot raise the access prompt it is meant to detect. + */ +export async function probeMacosKeychainReachability( + run: typeof runProcess = runProcess +): Promise { + let result: Awaited> + try { + result = await run({ + program: '/usr/bin/security', + args: ['show-keychain-info'], + timeoutMs: PROBE_TIMEOUT_MS, + maxOutputBytes: 16 * 1024, + killOnOutputLimit: true + }) + } catch { + return { kind: 'unknown' } + } + if (result.timedOut) { + return { kind: 'unavailable', reason: 'timeout' } + } + if (result.code === 0) { + return { kind: 'reachable' } + } + // Why exit codes too: security(1) exits with the OSStatus low byte, so a localized message + // still classifies (errSecNoDefaultKeychain -25307 = 37, errSecInteractionNotAllowed -25308 = 36). + const output = `${result.stderr}\n${result.stdout}` + if (result.code === 37 || /default keychain could not be found/i.test(output)) { + return { kind: 'unavailable', reason: 'no_default_keychain' } + } + if (result.code === 36 || /interaction is not allowed/i.test(output)) { + return { kind: 'unavailable', reason: 'interaction_not_allowed' } + } + return { kind: 'unknown' } +} + +export function describeKeychainUnavailable( + reason: Extract['reason'] +): string { + switch (reason) { + case 'no_default_keychain': + return 'this login has no default macOS keychain' + case 'interaction_not_allowed': + return 'the macOS keychain needs a prompt that this session cannot show' + case 'timeout': + return 'the macOS keychain did not answer within 5 seconds' + } +} diff --git a/src/main/host/safe-storage-availability.test.ts b/src/main/host/safe-storage-availability.test.ts new file mode 100644 index 000000000000..fc0d47c3f1a0 --- /dev/null +++ b/src/main/host/safe-storage-availability.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(`sealed:${value}`)), + decryptString: vi.fn((cipher: Buffer) => cipher.toString().replace('sealed:', '')) +})) +vi.mock('electron', () => ({ safeStorage: safeStorageMock })) + +const { + guardedSafeStorage, + resetSafeStorageWithholdForTests, + safeStorageWithheldReason, + withholdSafeStorage +} = await import('./safe-storage-availability') +const { ElectronSecretStore } = await import('./electron-secret-store') + +describe('guardedSafeStorage', () => { + afterEach(() => { + resetSafeStorageWithholdForTests() + vi.clearAllMocks() + }) + + it('passes through until the keychain is withheld', () => { + expect(guardedSafeStorage.isEncryptionAvailable()).toBe(true) + expect(guardedSafeStorage.decryptString(guardedSafeStorage.encryptString('token'))).toBe( + 'token' + ) + }) + + it('never reaches the native keychain once withheld', () => { + withholdSafeStorage('no default keychain') + withholdSafeStorage('a later reason') + + expect(safeStorageWithheldReason()).toBe('no default keychain') + expect(guardedSafeStorage.isEncryptionAvailable()).toBe(false) + expect(() => guardedSafeStorage.encryptString('token')).toThrow('secrets_unavailable') + expect(() => guardedSafeStorage.decryptString(Buffer.from('x'))).toThrow('secrets_unavailable') + expect(safeStorageMock.isEncryptionAvailable).not.toHaveBeenCalled() + expect(safeStorageMock.encryptString).not.toHaveBeenCalled() + }) + + it('makes the secret store report why secrets are unavailable, without probing', () => { + withholdSafeStorage('this login has no default macOS keychain') + const store = new ElectronSecretStore() + + expect(store.isEncryptionAvailable()).toBe(false) + expect(store.describeProtectionGap()).toContain('this login has no default macOS keychain') + expect(safeStorageMock.isEncryptionAvailable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/host/safe-storage-availability.ts b/src/main/host/safe-storage-availability.ts new file mode 100644 index 000000000000..e769b68b62ba --- /dev/null +++ b/src/main/host/safe-storage-availability.ts @@ -0,0 +1,49 @@ +import { safeStorage } from 'electron' + +let withheldReason: string | null = null + +/** + * Stop every in-process keychain call for the rest of this run. + * + * Why: `safeStorage` is synchronous, and against a keychain that needs a prompt nobody can + * answer (headless serve with no GUI session) it blocks the main thread indefinitely. Once + * an out-of-process probe has shown that, callers get "unavailable" instead of a frozen host. + */ +export function withholdSafeStorage(reason: string): void { + withheldReason ??= reason +} + +export function safeStorageWithheldReason(): string | null { + return withheldReason +} + +export class SafeStorageWithheldError extends Error { + constructor(reason: string) { + super(`secrets_unavailable: ${reason}`) + this.name = 'SafeStorageWithheldError' + } +} + +/** `safeStorage` behind the run-wide withhold; use this, never `electron.safeStorage`. */ +export const guardedSafeStorage = { + isEncryptionAvailable(): boolean { + return withheldReason === null && safeStorage.isEncryptionAvailable() + }, + encryptString(plainText: string): Buffer { + if (withheldReason !== null) { + throw new SafeStorageWithheldError(withheldReason) + } + return safeStorage.encryptString(plainText) + }, + decryptString(cipher: Buffer): string { + if (withheldReason !== null) { + throw new SafeStorageWithheldError(withheldReason) + } + return safeStorage.decryptString(cipher) + } +} + +/** Test seam: restore the unwithheld state. */ +export function resetSafeStorageWithholdForTests(): void { + withheldReason = null +} diff --git a/src/main/host/safe-storage-import-ratchet.test.ts b/src/main/host/safe-storage-import-ratchet.test.ts new file mode 100644 index 000000000000..a008a0e90916 --- /dev/null +++ b/src/main/host/safe-storage-import-ratchet.test.ts @@ -0,0 +1,31 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const MAIN_ROOT = join(__dirname, '..') +// Why: only these may touch electron.safeStorage directly; every keychain read or write must +// go through guardedSafeStorage so a withheld keychain can never block the main thread. +const ALLOWED = new Set(['host/safe-storage-availability.ts', 'host/electron-secret-store.ts']) + +function listSources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listSources(path) + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +describe('electron.safeStorage importers', () => { + it('stay behind the keychain withhold', () => { + const importers = listSources(MAIN_ROOT) + .filter((path) => + /import\s*\{[^}]*\bsafeStorage\b[^}]*\}\s*from\s*'electron'/.test( + readFileSync(path, 'utf8') + ) + ) + .map((path) => relative(MAIN_ROOT, path).split('\\').join('/')) + expect(importers.filter((path) => !ALLOWED.has(path))).toEqual([]) + }) +}) diff --git a/src/main/host/serve-keychain-guard.test.ts b/src/main/host/serve-keychain-guard.test.ts new file mode 100644 index 000000000000..040a23b50390 --- /dev/null +++ b/src/main/host/serve-keychain-guard.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn(), + decryptString: vi.fn() + } +})) + +const { withholdUnreachableKeychainForServe } = await import('./serve-keychain-guard') +const { resetSafeStorageWithholdForTests, safeStorageWithheldReason } = + await import('./safe-storage-availability') + +describe('withholdUnreachableKeychainForServe', () => { + afterEach(() => resetSafeStorageWithholdForTests()) + + it('withholds the keychain and says why when macOS has none to offer', async () => { + const warn = vi.fn() + await withholdUnreachableKeychainForServe({ + platform: 'darwin', + probe: async () => ({ kind: 'unavailable', reason: 'no_default_keychain' }), + warn + }) + expect(safeStorageWithheldReason()).toBe('this login has no default macOS keychain') + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Secrets are unavailable for this run') + ) + }) + + it('leaves a reachable or undecided keychain alone, and never probes off macOS', async () => { + const probe = vi.fn(async () => ({ kind: 'reachable' as const })) + await withholdUnreachableKeychainForServe({ platform: 'darwin', probe }) + await withholdUnreachableKeychainForServe({ + platform: 'darwin', + probe: async () => ({ kind: 'unknown' }) + }) + expect(safeStorageWithheldReason()).toBeNull() + + const skippedProbe = vi.fn() + await withholdUnreachableKeychainForServe({ platform: 'linux', probe: skippedProbe }) + await withholdUnreachableKeychainForServe({ + platform: 'darwin', + usesMockKeychain: true, + probe: skippedProbe + }) + expect(skippedProbe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/host/serve-keychain-guard.ts b/src/main/host/serve-keychain-guard.ts new file mode 100644 index 000000000000..22d26dcaf700 --- /dev/null +++ b/src/main/host/serve-keychain-guard.ts @@ -0,0 +1,36 @@ +import { + describeKeychainUnavailable, + probeMacosKeychainReachability +} from './macos-keychain-reachability' +import { withholdSafeStorage } from './safe-storage-availability' + +/** + * Before headless serve touches a secret, prove the keychain can answer without a prompt; + * if it cannot, report secrets as unavailable for this run instead of hanging readiness. + * + * Why serve only: the desktop has a GUI session that can show the prompt. Why the probe is + * bounded: it runs in a child process that is killed after 5 s, so the worst case is a + * 5 s delay, never the indefinite in-process block it replaces (#25453). + */ +export async function withholdUnreachableKeychainForServe( + deps: { + platform?: NodeJS.Platform + /** A mock keychain never prompts, so there is nothing to probe. */ + usesMockKeychain?: boolean + probe?: typeof probeMacosKeychainReachability + warn?: (message: string) => void + } = {} +): Promise { + if ((deps.platform ?? process.platform) !== 'darwin' || deps.usesMockKeychain === true) { + return + } + const reachability = await (deps.probe ?? probeMacosKeychainReachability)() + if (reachability.kind !== 'unavailable') { + return + } + const reason = describeKeychainUnavailable(reachability.reason) + withholdSafeStorage(reason) + ;(deps.warn ?? console.warn)( + `[secrets] Secrets are unavailable for this run: ${reason}. Orca will not use the keychain until it restarts, so sealed settings such as a saved proxy URL are not applied. Start orca serve from a logged-in desktop session, or unlock the login keychain, to seal credentials.` + ) +} diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 28155411e8aa..0fb54dcd6b11 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability' import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts index 4741a4715440..1001899835b9 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' -import { safeStorage } from 'electron' +import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability' import { isUnreadableError, writeSecureJsonFile } from '../../shared/secure-file' import type { OrcaCloudCapabilities, diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 0f199ef3a9f8..5d8eb854b7c0 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -11,6 +11,7 @@ * Chromium proves available at startup. */ import process from 'node:process' +import { describeOrcadSecretProtectionGap } from './orcad-secret-protection-gap' import { getAppEnvironment, setAppEnvironment, @@ -94,8 +95,7 @@ function createNodeSecretStore(): SecretStore { decryptString: () => { throw new Error('orcad_secret_sealing_unavailable') }, - describeProtectionGap: () => - 'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.' + describeProtectionGap: () => describeOrcadSecretProtectionGap(process.platform) } } diff --git a/src/main/orcad/orcad-secret-protection-gap.test.ts b/src/main/orcad/orcad-secret-protection-gap.test.ts new file mode 100644 index 000000000000..b4d908815261 --- /dev/null +++ b/src/main/orcad/orcad-secret-protection-gap.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { describeOrcadSecretProtectionGap } from './orcad-secret-protection-gap' + +describe('describeOrcadSecretProtectionGap', () => { + it('names the keychain orcad does not use instead of claiming the host has none', () => { + expect(describeOrcadSecretProtectionGap('darwin')).toContain('the macOS Keychain') + expect(describeOrcadSecretProtectionGap('win32')).toContain('Windows credential protection') + expect(describeOrcadSecretProtectionGap('linux')).toContain('the OS keyring') + for (const platform of ['darwin', 'win32', 'linux'] as const) { + expect(describeOrcadSecretProtectionGap(platform)).not.toContain('has no OS keyring') + } + }) +}) diff --git a/src/main/orcad/orcad-secret-protection-gap.ts b/src/main/orcad/orcad-secret-protection-gap.ts new file mode 100644 index 000000000000..46b411ebdc73 --- /dev/null +++ b/src/main/orcad/orcad-secret-protection-gap.ts @@ -0,0 +1,11 @@ +// Why per platform: macOS and Windows desktops do have an OS keychain; orcad just does not use +// it, so "this host has no keyring" sent users looking for one they already had. +export function describeOrcadSecretProtectionGap(platform: NodeJS.Platform): string { + const keychain = + platform === 'darwin' + ? 'the macOS Keychain' + : platform === 'win32' + ? 'Windows credential protection' + : 'the OS keyring' + return `Orca's headless server does not use ${keychain}, so credentials are stored unencrypted on this host. Pair from a desktop to manage secrets.` +} diff --git a/src/main/plugins/plugin-secrets-store.ts b/src/main/plugins/plugin-secrets-store.ts index fb41aba71c8f..b0c95e1161a7 100644 --- a/src/main/plugins/plugin-secrets-store.ts +++ b/src/main/plugins/plugin-secrets-store.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import { safeStorage } from 'electron' +import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability' import { isUnreadableError, writeSecureFile } from '../../shared/secure-file' import { PLUGIN_STORAGE_KEY_LIMIT, diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index e7bc3086a307..9439ee3adc1c 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -12,6 +12,7 @@ import { getCanonicalUserDataPath } from '../persistence' import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' +import { withholdUnreachableKeychainForServe } from '../host/serve-keychain-guard' import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' import { initOrcadHeldFenceTokenFile } from '../ssh/orcad-held-fence-tokens' import { neutralizeLegacyTerminalShimDir } from '../pty/legacy-terminal-shim-dir' @@ -90,6 +91,12 @@ export async function initializeReadyFoundation(): Promise { // existing post-ready rename, which lands after the Keychain name is already resolved. app.setName(identity.appName) updateGpuAccelerationAboutPanel() + if (state.isServeMode) { + // Why before the profile loads: loading can decrypt secrets, which is the call that hangs. + await withholdUnreachableKeychainForServe({ + usesMockKeychain: app.commandLine.hasSwitch('use-mock-keychain') + }) + } // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. state.managedWslCliReconciliationStatus = 'pending' state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index 7aad68366610..c4e217e56cb4 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -1,4 +1,4 @@ -import { safeStorage } from 'electron' +import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability' import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' From 7263f4985beda74906f56b006f9aeca5e060baec Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:36:09 -0700 Subject: [PATCH 48/58] refactor(rpc): name the terminal.subscribe modules (#27444) Co-authored-by: m4air --- ...erminal-subscribe-binary-control-frames.ts} | 8 ++++---- ...ve.ts => terminal-subscribe-binary-live.ts} | 10 +++++----- ...s => terminal-subscribe-binary-snapshot.ts} | 12 ++++++------ ...-binary.ts => terminal-subscribe-binary.ts} | 18 +++++++++--------- ...ts => terminal-subscribe-json-and-lease.ts} | 4 ++-- .../terminal/terminal-subscribe-method.ts | 6 +++--- ...on-types.ts => terminal-subscribe-types.ts} | 2 +- 7 files changed, 30 insertions(+), 30 deletions(-) rename src/main/runtime/rpc/methods/terminal/{terminal-legacy-binary-control-frames.ts => terminal-subscribe-binary-control-frames.ts} (94%) rename src/main/runtime/rpc/methods/terminal/{terminal-legacy-subscribe-live.ts => terminal-subscribe-binary-live.ts} (96%) rename src/main/runtime/rpc/methods/terminal/{terminal-legacy-subscribe-snapshot.ts => terminal-subscribe-binary-snapshot.ts} (96%) rename src/main/runtime/rpc/methods/terminal/{terminal-legacy-subscribe-binary.ts => terminal-subscribe-binary.ts} (93%) rename src/main/runtime/rpc/methods/terminal/{terminal-legacy-simple-subscriptions.ts => terminal-subscribe-json-and-lease.ts} (94%) rename src/main/runtime/rpc/methods/terminal/{terminal-legacy-subscription-types.ts => terminal-subscribe-types.ts} (97%) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-control-frames.ts similarity index 94% rename from src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts rename to src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-control-frames.ts index 1033464071c6..f8bf36789d09 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-control-frames.ts @@ -4,10 +4,10 @@ import { decodeTerminalStreamText } from '../../../../../shared/terminal-stream-protocol' import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' -import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import type { TerminalSubscriptionArgs } from './terminal-subscribe-types' import { updateViewportForClient } from './terminal-viewport-update' -type LegacyBinaryControlState = { +type TerminalSubscribeBinaryControlState = { isClosed: () => boolean isBuffering: () => boolean setRegisteredRemoteDesktopDriver: () => void @@ -17,11 +17,11 @@ type LegacyBinaryControlState = { sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void } -export function registerLegacyBinaryControlFrames( +export function registerTerminalSubscribeBinaryControlFrames( args: TerminalSubscriptionArgs, streamId: number, remoteDesktopSubscriptionKey: string, - controls: LegacyBinaryControlState + controls: TerminalSubscribeBinaryControlState ): () => void { const { params, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-live.ts similarity index 96% rename from src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts rename to src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-live.ts index da5761dffcfe..f1b591e29f3e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-live.ts @@ -10,13 +10,13 @@ import { } from './terminal-snapshot-publication' import { updateViewportForClient } from './terminal-viewport-update' import type { - LegacyBinarySubscriptionState, + TerminalSubscribeBinaryState, TerminalSubscriptionArgs -} from './terminal-legacy-subscription-types' +} from './terminal-subscribe-types' -export function activateLegacyBinarySubscription( +export function activateTerminalSubscribeBinaryLive( args: TerminalSubscriptionArgs, - state: LegacyBinarySubscriptionState + state: TerminalSubscribeBinaryState ): void { const { params, runtime, emit, ptyId, clientId, isMobile, supportsDesktopViewportClaims } = args const lateRendererReady = state.lateRendererReadyPromise @@ -152,7 +152,7 @@ export function activateLegacyBinarySubscription( ).catch(() => {}) } - // Legacy fit-override-changed for non-mobile (desktop) subscribers + // fit-override-changed for non-mobile (desktop) subscribers state.unsubscribeFit = !isMobile ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { const mode = diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts similarity index 96% rename from src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts rename to src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts index f192b40b0d81..22c323813489 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary-snapshot.ts @@ -11,15 +11,15 @@ import { trimPendingOutputCoveredBySnapshot } from './terminal-stream-replay' import type { - LegacyBinarySubscriptionState, + TerminalSubscribeBinaryState, TerminalSubscriptionArgs -} from './terminal-legacy-subscription-types' +} from './terminal-subscribe-types' const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 -export async function publishLegacyBinaryInitialSnapshot( +export async function publishTerminalSubscribeBinarySnapshot( args: TerminalSubscriptionArgs, - state: LegacyBinarySubscriptionState + state: TerminalSubscribeBinaryState ): Promise { const { params, @@ -38,7 +38,7 @@ export async function publishLegacyBinaryInitialSnapshot( if (isMobile && clientId) { await registration.addMobilePresence(ptyId, clientId, params.viewport) } else if (clientId && params.viewport) { - // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. + // Why: terminal.subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. state.registeredRemoteDesktopDriver = true state.pendingRemoteDesktopViewport = params.viewport } @@ -83,7 +83,7 @@ export async function publishLegacyBinaryInitialSnapshot( !rendererReady && (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) if (mountRequested) { - // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. + // Why: an idle PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. const mountWaitController = new AbortController() const abortMountWait = (): void => mountWaitController.abort() state.abortRendererMountWait = abortMountWait diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts similarity index 93% rename from src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts rename to src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts index 35d40a2d9485..59158b348c35 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-binary.ts @@ -16,13 +16,13 @@ import { createTerminalOutputBatcher, type TerminalOutputBatcher } from './termi import { trimPendingOutputToBudget } from './terminal-stream-replay' import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' import type { - LegacyBinarySubscriptionState, + TerminalSubscribeBinaryState, TerminalSubscriptionArgs -} from './terminal-legacy-subscription-types' +} from './terminal-subscribe-types' import type { TerminalOutputChunk } from './terminal-stream-types' -import { publishLegacyBinaryInitialSnapshot } from './terminal-legacy-subscribe-snapshot' -import { activateLegacyBinarySubscription } from './terminal-legacy-subscribe-live' -import { registerLegacyBinaryControlFrames } from './terminal-legacy-binary-control-frames' +import { publishTerminalSubscribeBinarySnapshot } from './terminal-subscribe-binary-snapshot' +import { activateTerminalSubscribeBinaryLive } from './terminal-subscribe-binary-live' +import { registerTerminalSubscribeBinaryControlFrames } from './terminal-subscribe-binary-control-frames' const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise { const { runtime, registration, sendBinary, ptyId, clientId, isMobile } = args @@ -97,7 +97,7 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) } }) - unregisterBinaryHandler = registerLegacyBinaryControlFrames( + unregisterBinaryHandler = registerTerminalSubscribeBinaryControlFrames( args, streamId, remoteDesktopSubscriptionKey, @@ -164,7 +164,7 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr releaseViewSubscriber() unsubscribeStreamData() } - const state: LegacyBinarySubscriptionState = { + const state: TerminalSubscribeBinaryState = { streamId, remoteDesktopSubscriptionKey, get closed() { @@ -257,10 +257,10 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr streamClosed, sendFrame } - await publishLegacyBinaryInitialSnapshot(args, state) + await publishTerminalSubscribeBinarySnapshot(args, state) if (registration.released) { return } - activateLegacyBinarySubscription(args, state) + activateTerminalSubscribeBinaryLive(args, state) await streamClosed } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts similarity index 94% rename from src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts rename to src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts index 9a52d8f585c4..350e73c10146 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-json-and-lease.ts @@ -3,7 +3,7 @@ import type { TerminalOutputBatcher } from './terminal-output-batcher' import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' import { updateViewportForClient } from './terminal-viewport-update' -import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import type { TerminalSubscriptionArgs } from './terminal-subscribe-types' import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' export async function runTerminalLeaseSubscription(args: TerminalSubscriptionArgs): Promise { @@ -93,7 +93,7 @@ export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { outputBatcher?.push(data) }) - // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. + // Why: the terminal.subscribe JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) unsubscribeData = () => { releaseViewSubscriber() diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index 0b1ae4e54b4c..cc5ae0d7796a 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -1,12 +1,12 @@ import { defineStreamingMethod } from '../../core' import { TerminalSubscribe } from '../../../../../shared/rpc-contract/terminal-stream-params' import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' -import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary' +import { runTerminalBinarySubscription } from './terminal-subscribe-binary' import { runTerminalJsonSubscription, runTerminalLeaseSubscription -} from './terminal-legacy-simple-subscriptions' -import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +} from './terminal-subscribe-json-and-lease' +import type { TerminalSubscriptionArgs } from './terminal-subscribe-types' import { registerTerminalSubscription } from './terminal-subscription-registration' export const TERMINAL_SUBSCRIBE_METHODS = [ diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts similarity index 97% rename from src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts rename to src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts index 543760e3b65d..de4b1f5bdcea 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-types.ts @@ -27,7 +27,7 @@ export type TerminalSubscriptionArgs = { serializerGenerationBeforeMobileFit: number } -export type LegacyBinarySubscriptionState = { +export type TerminalSubscribeBinaryState = { readonly streamId: number readonly remoteDesktopSubscriptionKey: string closed: boolean From c2624abcfc20a0e61f24484fa79986bb7e19ff3b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:36:48 -0700 Subject: [PATCH 49/58] feat(daemon): process-growth breaker that pauses automatic restores (#27437) * feat(daemon): process-growth breaker that pauses automatic restores per host and names the agent * fix(daemon): breaker trips only on sustained growth, pauses in memory, observes on desktop * fix(daemon): sample fast only while a tree is rising or tripped --------- Co-authored-by: m4air --- .../daemon-automatic-work-guard.test.ts | 79 +++++++++ .../daemon/daemon-automatic-work-guard.ts | 93 +++++++++++ .../daemon/daemon-process-growth-monitor.ts | 153 ++++++++++++++++++ src/main/daemon/daemon-server.ts | 29 ++-- src/main/daemon/terminal-spawn-gate.test.ts | 29 +++- src/main/daemon/terminal-spawn-gate.ts | 16 ++ src/shared/host-spawn-admission.test.ts | 9 +- src/shared/host-spawn-admission.ts | 11 +- src/shared/process-growth-breaker.test.ts | 94 +++++++++++ src/shared/process-growth-breaker.ts | 153 ++++++++++++++++++ 10 files changed, 645 insertions(+), 21 deletions(-) create mode 100644 src/main/daemon/daemon-automatic-work-guard.test.ts create mode 100644 src/main/daemon/daemon-automatic-work-guard.ts create mode 100644 src/main/daemon/daemon-process-growth-monitor.ts create mode 100644 src/shared/process-growth-breaker.test.ts create mode 100644 src/shared/process-growth-breaker.ts diff --git a/src/main/daemon/daemon-automatic-work-guard.test.ts b/src/main/daemon/daemon-automatic-work-guard.test.ts new file mode 100644 index 000000000000..c1fe9ffa4ee5 --- /dev/null +++ b/src/main/daemon/daemon-automatic-work-guard.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonAutomaticWorkGuard } from './daemon-automatic-work-guard' +import { DaemonProcessGrowthMonitor } from './daemon-process-growth-monitor' +import type { TerminalSpawnAdmissionMode } from './terminal-spawn-gate' + +describe('DaemonAutomaticWorkGuard growth breaker', () => { + let dir: string + let rows: { pid: number; ppid: number }[] + let now: number + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-growth-')) + rows = [{ pid: 100, ppid: 1 }] + now = 0 + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + async function stormUnderOneAgent(mode: TerminalSpawnAdmissionMode) { + const log = { log: vi.fn() } + const markerPath = join(dir, 'daemon-v1.automatic-work-run.json') + let guard: DaemonAutomaticWorkGuard | undefined + const monitor = new DaemonProcessGrowthMonitor({ + listSessions: () => [{ sessionId: 'agent-session-0001', pid: 100 }], + recentAutomaticWork: () => true, + sampleWhenIdle: true, + onLevelChanged: (level) => guard?.['onPressureChanged'](level), + onTrip: (trips) => guard?.['onTrip'](trips), + readRows: async () => rows, + rootPid: 1, + now: () => now + }) + guard = new DaemonAutomaticWorkGuard({ + markerPath, + mode, + log, + listSessions: () => [], + ownsEndpoint: () => true, + monitor + }) + guard.start() + await monitor.sample() + for (let step = 1; step <= 3; step++) { + now = step * 5_000 + for (let i = 0; i < 300; i++) { + rows.push({ pid: 1_000 * step + i, ppid: 100 }) + } + await monitor.sample() + } + monitor.stop() + return { guard, monitor, log, markerPath } + } + + it('pauses automatic work for this run and names the agent whose tree is storming', async () => { + const { guard, monitor, log, markerPath } = await stormUnderOneAgent('enforce') + + expect(monitor.level).toBe('critical') + expect(guard['automaticWorkMode']()).toMatchObject({ kind: 'paused', reason: 'pressure' }) + expect(log.log).toHaveBeenCalledWith( + 'process-growth-breaker-tripped', + expect.objectContaining({ + trips: expect.arrayContaining([expect.objectContaining({ scope: 'session-0001' })]) + }) + ) + // Nothing can resume a recorded pause yet, so the breaker never writes one. + expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toMatchObject({ mode: { kind: 'normal' } }) + }) + + it('only reports a storm on an observe-only host', async () => { + const { guard, log } = await stormUnderOneAgent('observe') + expect(guard['automaticWorkMode']()).toEqual({ kind: 'normal' }) + expect(log.log).toHaveBeenCalledWith('process-growth-breaker-tripped', expect.anything()) + }) +}) diff --git a/src/main/daemon/daemon-automatic-work-guard.ts b/src/main/daemon/daemon-automatic-work-guard.ts new file mode 100644 index 000000000000..3f0a766cfa3f --- /dev/null +++ b/src/main/daemon/daemon-automatic-work-guard.ts @@ -0,0 +1,93 @@ +import type { AutomaticWorkMode, HostPressureLevel } from '../../shared/host-spawn-admission' +import type { ProcessGrowthTrip } from '../../shared/process-growth-breaker' +import { AutomaticWorkRunMarker } from './automatic-work-run-marker-store' +import { DaemonProcessGrowthMonitor } from './daemon-process-growth-monitor' +import { TerminalSpawnGate, type TerminalSpawnAdmissionMode } from './terminal-spawn-gate' + +type DaemonAutomaticWorkGuardOptions = { + markerPath: string | undefined + mode: TerminalSpawnAdmissionMode + log: { log: (event: string, details: Record) => void } + listSessions: () => readonly { sessionId: string; pid: number | null }[] + ownsEndpoint: () => boolean + /** Test seam for the growth monitor's process table. */ + monitor?: DaemonProcessGrowthMonitor +} + +/** + * Everything that decides whether the daemon may start automatic work: the fork gate, + * the same-boot crash marker, and the process-growth breaker that pauses it. + */ +export class DaemonAutomaticWorkGuard { + readonly gate: TerminalSpawnGate + private readonly marker: AutomaticWorkRunMarker + private readonly monitor: DaemonProcessGrowthMonitor + private pressurePausedSince: number | null = null + + constructor(private readonly options: DaemonAutomaticWorkGuardOptions) { + this.marker = new AutomaticWorkRunMarker(options.markerPath, options.log, options.ownsEndpoint) + this.monitor = + options.monitor ?? + new DaemonProcessGrowthMonitor({ + listSessions: options.listSessions, + recentAutomaticWork: () => this.gate.hasRecentAutomaticWork(), + sampleWhenIdle: options.mode === 'enforce', + onLevelChanged: (level) => this.onPressureChanged(level), + onTrip: (trips) => this.onTrip(trips) + }) + this.gate = new TerminalSpawnGate({ + log: (event, details) => options.log.log(event, details), + mode: options.mode, + pressure: () => this.monitor.level, + automaticWorkMode: () => this.automaticWorkMode(), + armAutomaticWork: () => this.marker.arm() + }) + } + + /** Called once the daemon owns its endpoint. */ + start(): void { + this.marker.open() + this.monitor.start() + } + + /** Called after every session was disposed on an orderly shutdown. */ + stop(): void { + this.monitor.stop() + this.marker.markCleanExit() + } + + /** An exit on purpose that skips teardown (macOS login-session retirement). */ + recordOrderlyExit(): void { + this.monitor.stop() + this.marker.markCleanExit() + } + + private onPressureChanged(level: HostPressureLevel): void { + this.options.log.log('process-growth-level', { level }) + this.gate.onPressureChanged() + } + + private automaticWorkMode(): AutomaticWorkMode { + const recorded = this.marker.mode + if (recorded.kind === 'paused' || this.pressurePausedSince === null) { + return recorded + } + return { kind: 'paused', reason: 'pressure', since: this.pressurePausedSince } + } + + private onTrip(trips: readonly ProcessGrowthTrip[]): void { + // Why pause, not kill: running agents keep running and only new automatic work stops + // (owner decision Q3). Why in memory for this run: nothing can resume a recorded pause + // yet, and an observe-only host must not leave one behind for when it starts enforcing. + if (this.options.mode === 'enforce') { + this.pressurePausedSince ??= Date.now() + } + this.options.log.log('process-growth-breaker-tripped', { + trips: trips.map((trip) => ({ + scope: trip.scopeId === null ? 'host' : trip.scopeId.slice(-12), + descendants: trip.descendants, + growthPerSecond: trip.growthPerSecond + })) + }) + } +} diff --git a/src/main/daemon/daemon-process-growth-monitor.ts b/src/main/daemon/daemon-process-growth-monitor.ts new file mode 100644 index 000000000000..8f51fc4dc8b1 --- /dev/null +++ b/src/main/daemon/daemon-process-growth-monitor.ts @@ -0,0 +1,153 @@ +import { readProcessTable } from '../pty-descendant-termination' +import { + isWindowsProcessTableAvailable, + readWindowsProcessIdentityTableFresh +} from '../windows/windows-process-table' +import type { HostPressureLevel } from '../../shared/host-spawn-admission' +import { + countDescendantsByRoot, + createProcessGrowthState, + DEFAULT_PROCESS_GROWTH_LIMITS, + evaluateProcessGrowth, + type ProcessGrowthLimits, + type ProcessGrowthTrip +} from '../../shared/process-growth-breaker' + +type ProcessRow = { pid: number; ppid: number } + +const ACTIVE_SAMPLE_INTERVAL_MS = 5_000 +const IDLE_SAMPLE_INTERVAL_MS = 30_000 +const MAX_REPORTED_TRIPS = 5 + +/** pid/ppid rows from a child process (POSIX `ps`) or the native Windows table; null when unreadable. */ +export async function readDaemonProcessRows(): Promise { + try { + if (process.platform === 'win32') { + return isWindowsProcessTableAvailable() ? await readWindowsProcessIdentityTableFresh() : null + } + return (await readProcessTable()).rows + } catch { + return null + } +} + +type DaemonProcessGrowthMonitorOptions = { + listSessions: () => readonly { sessionId: string; pid: number | null }[] + /** Samples fast while automatic work started recently, slowly otherwise. */ + recentAutomaticWork: () => boolean + /** Why: an observe-only host has nothing to protect, so it pays for `ps` only around restores. */ + sampleWhenIdle: boolean + onLevelChanged: (level: HostPressureLevel) => void + onTrip: (trips: readonly ProcessGrowthTrip[]) => void + readRows?: () => Promise + rootPid?: number + limits?: ProcessGrowthLimits + now?: () => number +} + +/** + * Watches the daemon's descendant tree, per terminal and in total. It never kills: + * a trip only closes admission of automatic work and reports which terminals grew. + */ +export class DaemonProcessGrowthMonitor { + private readonly state = createProcessGrowthState() + private readonly limits: ProcessGrowthLimits + private readonly now: () => number + private readonly readRows: () => Promise + private currentLevel: HostPressureLevel = 'unknown' + private rising = false + private timer: ReturnType | null = null + private stopped = false + + constructor(private readonly options: DaemonProcessGrowthMonitorOptions) { + this.limits = options.limits ?? DEFAULT_PROCESS_GROWTH_LIMITS + this.now = options.now ?? Date.now + this.readRows = options.readRows ?? readDaemonProcessRows + } + + get level(): HostPressureLevel { + return this.currentLevel + } + + start(): void { + this.schedule() + } + + stop(): void { + this.stopped = true + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + async sample(): Promise { + const sessions = this.options.listSessions().filter((session) => session.pid !== null) + if (sessions.length === 0) { + this.setLevel('ok') + return + } + const rows = await this.readRows() + if (this.stopped) { + return + } + const rootPid = this.options.rootPid ?? process.pid + const counts = rows + ? countDescendantsByRoot(rows, [rootPid, ...sessions.map((session) => session.pid ?? 0)]) + : null + const verdict = evaluateProcessGrowth( + this.state, + { + atMs: this.now(), + hostDescendants: counts?.get(rootPid) ?? null, + scopes: new Map( + counts + ? sessions.map((session) => [session.sessionId, counts.get(session.pid ?? 0) ?? 0]) + : [] + ) + }, + this.limits + ) + this.rising = verdict.rising + const wasCritical = this.currentLevel === 'critical' + this.setLevel(verdict.level) + if (verdict.level === 'critical' && !wasCritical) { + this.options.onTrip( + [...verdict.trips] + .sort((a, b) => b.growthPerSecond - a.growthPerSecond) + .slice(0, MAX_REPORTED_TRIPS) + ) + } + } + + private setLevel(level: HostPressureLevel): void { + if (level !== this.currentLevel) { + this.currentLevel = level + this.options.onLevelChanged(level) + } + } + + private schedule(): void { + if (this.stopped) { + return + } + // Why rising samples fast: a growing tree is confirmed over several short samples, never + // tripped on one long idle interval; a merely large tree keeps the slow cadence. + const confirming = this.rising || this.currentLevel === 'critical' + const delayMs = + this.options.recentAutomaticWork() || confirming + ? ACTIVE_SAMPLE_INTERVAL_MS + : IDLE_SAMPLE_INTERVAL_MS + this.timer = setTimeout(() => { + this.timer = null + if (!this.options.sampleWhenIdle && !this.options.recentAutomaticWork() && !confirming) { + this.schedule() + return + } + void this.sample() + .catch(() => this.setLevel('unknown')) + .finally(() => this.schedule()) + }, delayMs) + this.timer.unref?.() + } +} diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7fdc04e8b41f..778fef4ef2fc 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -21,8 +21,7 @@ import { DaemonTerminalAdmission } from './daemon-terminal-admission' import { checkPtySpawnHealth } from './pty-subprocess' import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import { TerminalHost } from './terminal-host' -import { TerminalSpawnGate } from './terminal-spawn-gate' -import { AutomaticWorkRunMarker } from './automatic-work-run-marker-store' +import { DaemonAutomaticWorkGuard } from './daemon-automatic-work-guard' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -35,7 +34,7 @@ export class DaemonServer { private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 private readonly log: DaemonFileLog - private readonly automaticWork: AutomaticWorkRunMarker + private readonly automaticWork: DaemonAutomaticWorkGuard private readonly host: TerminalHost private readonly transientFactRelay: BackgroundTransientFactRelay private readonly streamDataBatcher: DaemonStreamDataBatcher @@ -48,22 +47,20 @@ export class DaemonServer { private readonly admission: DaemonTerminalAdmission private readonly requestRouter: DaemonRequestRouter private stopStreamBacklogProbe: () => void = () => {} - readonly recordOrderlyExit = (): void => this.automaticWork.markCleanExit() + readonly recordOrderlyExit = (): void => this.automaticWork.recordOrderlyExit() constructor(options: DaemonServerOptions) { this.log = options.log ?? createNoopDaemonFileLog() - this.automaticWork = new AutomaticWorkRunMarker( - options.automaticWorkMarkerPath, - this.log, - () => !this.endpoint.lost - ) + this.automaticWork = new DaemonAutomaticWorkGuard({ + markerPath: options.automaticWorkMarkerPath, + mode: options.spawnAdmission ?? 'observe', + log: this.log, + listSessions: () => this.host.listSessions(), + ownsEndpoint: () => !this.endpoint.lost + }) this.host = new TerminalHost({ spawnSubprocess: options.spawnSubprocess, - spawnGate: new TerminalSpawnGate({ - log: (event, details) => this.log.log(event, details), - mode: options.spawnAdmission ?? 'observe', - ...this.automaticWork.gateOptions() - }), + spawnGate: this.automaticWork.gate, reportReadinessEvent: (event, details) => this.log.log(event, details), onSessionReaped: (sessionId) => { this.attachments.release(sessionId) @@ -247,7 +244,7 @@ export class DaemonServer { async start(): Promise { await this.lifecycle.start((socket) => this.connections.accept(socket)) // Why after start: only the daemon that won the endpoint may judge or rewrite the marker. - this.automaticWork.open() + this.automaticWork.start() } shutdown(): Promise { @@ -279,7 +276,7 @@ export class DaemonServer { this.streamDataBatcher.clear() this.historySeedTransfers.dispose() // Why after host dispose: every automatic session is gone, so this exit was orderly. - this.automaticWork.markCleanExit() + this.automaticWork.stop() // Let canceled in-flight RPCs enqueue their protocol errors before transport destruction. await waitForImmediate() this.connections.dispose() diff --git a/src/main/daemon/terminal-spawn-gate.test.ts b/src/main/daemon/terminal-spawn-gate.test.ts index 2e0f518f743a..9ac777568659 100644 --- a/src/main/daemon/terminal-spawn-gate.test.ts +++ b/src/main/daemon/terminal-spawn-gate.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import type { SubprocessHandle } from './session-subprocess-handle' import { TerminalHost, type TerminalHostOptions } from './terminal-host' import { TerminalSpawnGate, type TerminalSpawnAdmissionMode } from './terminal-spawn-gate' -import type { SpawnIntent } from '../../shared/host-spawn-admission' +import type { HostPressureLevel, SpawnIntent } from '../../shared/host-spawn-admission' type SpawnSubprocess = TerminalHostOptions['spawnSubprocess'] @@ -267,6 +267,33 @@ describe('TerminalSpawnGate', () => { expect(armAutomaticWork).toHaveBeenCalledOnce() }) + it('lets an undeclared restore wait out a growth storm, refusing it only past its deadline', async () => { + let pressure: HostPressureLevel = 'critical' + gate = new TerminalSpawnGate({ + log, + mode: 'enforce', + now: () => Date.now(), + pressure: () => pressure + }) + host = new TerminalHost({ spawnSubprocess, spawnGate: gate }) + const cleared = restore('cleared', { deadlineAt: 5_000 }) + await vi.advanceTimersByTimeAsync(1_000) + expect(spawnSubprocess).not.toHaveBeenCalled() + pressure = 'ok' + gate.onPressureChanged() + await expect(cleared).resolves.toMatchObject({ isNew: true }) + + pressure = 'critical' + const refused = restore('refused', { deadlineAt: Date.now() + 2_000 }) + const assertion = expect(refused).rejects.toThrow('terminal_spawn_refused') + await vi.advanceTimersByTimeAsync(2_000) + await assertion + await expect(restore('declared', { declaredIntent: 'automatic-restore' })).rejects.toThrow( + 'terminal_spawn_deferred:pressure' + ) + expect(spawnSubprocess).toHaveBeenCalledOnce() + }) + it('never queues an interactive shell behind a restore storm', async () => { for (let i = 0; i < 3; i++) { await restore(`burst-${i}`) diff --git a/src/main/daemon/terminal-spawn-gate.ts b/src/main/daemon/terminal-spawn-gate.ts index dc8de3a9a1e2..66bf95277d53 100644 --- a/src/main/daemon/terminal-spawn-gate.ts +++ b/src/main/daemon/terminal-spawn-gate.ts @@ -20,6 +20,8 @@ import { export type TerminalSpawnAdmissionMode = 'observe' | 'enforce' +const RECENT_AUTOMATIC_WORK_MS = 10 * 60_000 + export const TERMINAL_SPAWN_ADMISSION_ARG = '--spawn-admission' /** Reads the launcher's `--spawn-admission `; anything else observes. */ @@ -80,6 +82,7 @@ export class TerminalSpawnGate { private readonly liveAutomaticSessions = new Set() private readonly waiters = new Set<() => void>() private waited = 0 + private lastAutomaticAdmitAt: number | null = null private deferred = 0 private refused = 0 @@ -149,6 +152,18 @@ export class TerminalSpawnGate { return { kind: 'wait', untilMs: Math.min(decision.retryAt ?? Infinity, facts.deadlineAt) } } + /** A pressure change can let a waiter through, or make it give up. */ + onPressureChanged(): void { + this.wakeWaiters() + } + + hasRecentAutomaticWork(now: number = this.now()): boolean { + return ( + this.lastAutomaticAdmitAt !== null && + now - this.lastAutomaticAdmitAt < RECENT_AUTOMATIC_WORK_MS + ) + } + onSessionExit(sessionId: string): void { if (this.liveAutomaticSessions.delete(sessionId)) { releaseLiveAutomatic(this.state) @@ -171,6 +186,7 @@ export class TerminalSpawnGate { private lease(facts: SpawnRequestFacts, charge: SpawnCharge, now: number): TerminalSpawnLease { if (charge === 'automatic') { this.options.armAutomaticWork?.() + this.lastAutomaticAdmitAt = now } applySpawnAdmission(this.state, facts, charge, this.limits, now) let settled = false diff --git a/src/shared/host-spawn-admission.test.ts b/src/shared/host-spawn-admission.test.ts index 940e72e75286..934d8421f35e 100644 --- a/src/shared/host-spawn-admission.test.ts +++ b/src/shared/host-spawn-admission.test.ts @@ -111,7 +111,14 @@ describe('decideSpawnAdmission', () => { expect(decide(state, negotiated, 0, 'ok', paused)).toEqual({ kind: 'defer', reason: 'paused' }) expect(decide(state, facts(), 0, 'ok', paused).kind).toBe('admit') expect(decide(state, negotiated, 0, 'critical')).toEqual({ kind: 'defer', reason: 'pressure' }) - expect(decide(state, facts(), 0, 'critical')).toEqual({ kind: 'refuse_retryable' }) + expect(decide(state, facts({ deadlineAt: 500 }), 0, 'critical')).toEqual({ + kind: 'wait', + charge: 'automatic', + retryAt: null + }) + expect(decide(state, facts({ deadlineAt: 500 }), 500, 'critical')).toEqual({ + kind: 'refuse_retryable' + }) state.liveAutomatic = LIMITS.maxLiveAutomatic expect(decide(state, negotiated, 0)).toEqual({ kind: 'defer', reason: 'live_cap' }) diff --git a/src/shared/host-spawn-admission.ts b/src/shared/host-spawn-admission.ts index be048504bb23..7fa693282568 100644 --- a/src/shared/host-spawn-admission.ts +++ b/src/shared/host-spawn-admission.ts @@ -175,9 +175,14 @@ export function decideSpawnAdmission(args: { } const negotiated = facts.declaredIntent !== null if (pressure === 'critical') { - // Why: an old caller clears its resume handle before spawning, so only critical - // pressure may refuse it, with the error it already treats as "reconnect". - return negotiated ? { kind: 'defer', reason: 'pressure' } : { kind: 'refuse_retryable' } + if (negotiated) { + return { kind: 'defer', reason: 'pressure' } + } + // Why: an old caller clears its resume handle before spawning, so it is refused only + // if the storm outlasts its whole admission window. + return now >= facts.deadlineAt + ? { kind: 'refuse_retryable' } + : { kind: 'wait', charge, retryAt: null } } if (negotiated && mode.kind === 'paused') { return { kind: 'defer', reason: 'paused' } diff --git a/src/shared/process-growth-breaker.test.ts b/src/shared/process-growth-breaker.test.ts new file mode 100644 index 000000000000..6bfb0fb40348 --- /dev/null +++ b/src/shared/process-growth-breaker.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + countDescendantsByRoot, + createProcessGrowthState, + DEFAULT_PROCESS_GROWTH_LIMITS as LIMITS, + evaluateProcessGrowth +} from './process-growth-breaker' + +function sample(atMs: number, host: number | null, scopes: Record = {}) { + return { atMs, hostDescendants: host, scopes: new Map(Object.entries(scopes)) } +} + +describe('evaluateProcessGrowth', () => { + it('trips only after three rising samples over 10 s, and names the agent that grew', () => { + const state = createProcessGrowthState() + const levels: string[] = [] + let verdict = evaluateProcessGrowth(state, sample(0, 10, { a: 5, b: 5 }), LIMITS) + for (let step = 1; step <= 3; step++) { + const t = step * 5_000 + verdict = evaluateProcessGrowth( + state, + sample(t, 10 + step * 250, { a: 5 + step * 250, b: 5 }), + LIMITS + ) + levels.push(verdict.level) + } + expect(levels).toEqual(['elevated', 'elevated', 'critical']) + expect(verdict.trips).toEqual( + expect.arrayContaining([{ scopeId: 'a', descendants: 755, growthPerSecond: 50 }]) + ) + expect(verdict.trips.some((trip) => trip.scopeId === 'b')).toBe(false) + }) + + it('never trips on one long interval, however large the jump', () => { + const state = createProcessGrowthState() + evaluateProcessGrowth(state, sample(0, 0), LIMITS) + expect(evaluateProcessGrowth(state, sample(30_000, 1_200), LIMITS).level).toBe('elevated') + }) + + it('resets the streak once growth stops', () => { + const state = createProcessGrowthState() + evaluateProcessGrowth(state, sample(0, 0), LIMITS) + evaluateProcessGrowth(state, sample(5_000, 300), LIMITS) + evaluateProcessGrowth(state, sample(10_000, 600), LIMITS) + expect(evaluateProcessGrowth(state, sample(15_000, 600), LIMITS).level).toBe('ok') + expect(evaluateProcessGrowth(state, sample(20_000, 900), LIMITS).level).toBe('elevated') + }) + + it('reports a high total as elevated load, never as a trip', () => { + const state = createProcessGrowthState() + expect(evaluateProcessGrowth(state, sample(0, LIMITS.hostElevatedDescendants), LIMITS)).toEqual( + { + level: 'elevated', + trips: [], + rising: false + } + ) + }) + + it('treats an unreadable table as unknown, never as a trip or a reset', () => { + const state = createProcessGrowthState() + evaluateProcessGrowth(state, sample(0, 0), LIMITS) + evaluateProcessGrowth(state, sample(5_000, 300), LIMITS) + evaluateProcessGrowth(state, sample(10_000, 600), LIMITS) + expect(evaluateProcessGrowth(state, sample(12_000, null), LIMITS)).toEqual({ + level: 'unknown', + trips: [], + rising: true + }) + expect(evaluateProcessGrowth(state, sample(15_000, 900), LIMITS).level).toBe('critical') + }) +}) + +describe('countDescendantsByRoot', () => { + it('counts each root tree and tolerates cycles', () => { + const rows = [ + { pid: 2, ppid: 1 }, + { pid: 3, ppid: 2 }, + { pid: 4, ppid: 2 }, + { pid: 5, ppid: 1 }, + { pid: 6, ppid: 7 }, + { pid: 7, ppid: 6 } + ] + expect(countDescendantsByRoot(rows, [1, 2, 5, 6, 99])).toEqual( + new Map([ + [1, 4], + [2, 2], + [5, 0], + [6, 1], + [99, 0] + ]) + ) + }) +}) diff --git a/src/shared/process-growth-breaker.ts b/src/shared/process-growth-breaker.ts new file mode 100644 index 000000000000..85277de08794 --- /dev/null +++ b/src/shared/process-growth-breaker.ts @@ -0,0 +1,153 @@ +// Pure, clock-injected breaker over descendant process counts. Each scope is one +// terminal's process tree (today: one agent per PTY); the host total covers them all. +// Why per scope: a per-agent OS ceiling can later act on exactly the scope that tripped. + +export type ProcessGrowthSample = { + atMs: number + /** Descendants of the whole host, or null when the table could not be read. */ + hostDescendants: number | null + scopes: ReadonlyMap +} + +export type ProcessGrowthLimits = { + /** A total this high is reported as elevated load; on its own it never trips. */ + hostElevatedDescendants: number + /** New descendants per second, per scope or host-wide, that count as a storm. */ + growthPerSecond: number + /** How long, and over how many consecutive rising samples, growth must hold to trip. */ + sustainMs: number + minRisingSamples: number +} + +export const DEFAULT_PROCESS_GROWTH_LIMITS: ProcessGrowthLimits = { + hostElevatedDescendants: 1500, + growthPerSecond: 40, + sustainMs: 10_000, + minRisingSamples: 3 +} + +export type ProcessGrowthTrip = { + /** null: the host total; otherwise the scope whose tree grew. */ + scopeId: string | null + descendants: number + growthPerSecond: number +} + +type RisingStreak = { since: number; samples: number } + +export type ProcessGrowthState = { + previous: ProcessGrowthSample | null + /** Current rising streak per scope (`''` = host). */ + streaks: Map +} + +export type ProcessGrowthVerdict = { + level: 'ok' | 'elevated' | 'critical' | 'unknown' + trips: ProcessGrowthTrip[] + /** A rising streak is open somewhere; confirm it with short samples. */ + rising: boolean +} + +const HOST_KEY = '' + +export function createProcessGrowthState(): ProcessGrowthState { + return { previous: null, streaks: new Map() } +} + +function growthRate(previous: number | undefined, current: number, elapsedMs: number): number { + return previous === undefined || elapsedMs <= 0 ? 0 : ((current - previous) * 1000) / elapsedMs +} + +/** + * Folds one sample into the state. Only sustained growth trips: a high total is load, + * not a storm, and one long sampling interval is never enough evidence on its own. + * `unknown` never trips and never clears a streak. + */ +export function evaluateProcessGrowth( + state: ProcessGrowthState, + sample: ProcessGrowthSample, + limits: ProcessGrowthLimits +): ProcessGrowthVerdict { + if (sample.hostDescendants === null) { + return { level: 'unknown', trips: [], rising: state.streaks.size > 0 } + } + const previous = state.previous + const elapsedMs = previous ? sample.atMs - previous.atMs : 0 + const trips: ProcessGrowthTrip[] = [] + let growing = false + const observe = (key: string, before: number | undefined, current: number): void => { + const rate = growthRate(before, current, elapsedMs) + if (rate < limits.growthPerSecond) { + state.streaks.delete(key) + return + } + growing = true + const streak = state.streaks.get(key) ?? { since: previous?.atMs ?? sample.atMs, samples: 0 } + streak.samples += 1 + state.streaks.set(key, streak) + if ( + streak.samples >= limits.minRisingSamples && + sample.atMs - streak.since >= limits.sustainMs + ) { + trips.push({ + scopeId: key === HOST_KEY ? null : key, + descendants: current, + growthPerSecond: Math.round(rate) + }) + } + } + observe(HOST_KEY, previous?.hostDescendants ?? undefined, sample.hostDescendants) + for (const [scopeId, count] of sample.scopes) { + observe(scopeId, previous?.scopes.get(scopeId), count) + } + for (const key of state.streaks.keys()) { + if (key !== HOST_KEY && !sample.scopes.has(key)) { + state.streaks.delete(key) + } + } + state.previous = sample + if (trips.length > 0) { + return { level: 'critical', trips, rising: true } + } + return { + level: growing || sample.hostDescendants >= limits.hostElevatedDescendants ? 'elevated' : 'ok', + trips, + rising: growing + } +} + +/** Descendant counts per root pid from a pid/ppid table. */ +export function countDescendantsByRoot( + rows: readonly { pid: number; ppid: number }[], + roots: readonly number[] +): Map { + const children = new Map() + for (const row of rows) { + if (row.pid === row.ppid) { + continue + } + const siblings = children.get(row.ppid) + if (siblings) { + siblings.push(row.pid) + } else { + children.set(row.ppid, [row.pid]) + } + } + const counts = new Map() + for (const root of roots) { + let count = 0 + const seen = new Set([root]) + const stack = [...(children.get(root) ?? [])] + while (stack.length > 0) { + const pid = stack.pop() + if (pid === undefined || seen.has(pid)) { + continue + } + seen.add(pid) + count += 1 + stack.push(...(children.get(pid) ?? [])) + } + counts.set(root, count) + } + return counts +} From 43bf79994794fb5ec6a34f03a7833d2794cd31f3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:43:48 -0700 Subject: [PATCH 50/58] fix(daemon): drop a legacy daemon that can no longer accept a connection (#27436) Co-authored-by: Wooseong Kim --- src/main/daemon/daemon-legacy-exit-proof.ts | 26 +++++ ...on-pty-adapter-subscription-fanout.test.ts | 78 +++++++++++++++ .../daemon-pty-adapter-subscription-fanout.ts | 33 ++++++- .../daemon-pty-router-legacy-gone.test.ts | 98 +++++++++++++++++++ src/main/daemon/daemon-pty-router.ts | 40 +++++++- .../daemon/daemon-session-owner-resolution.ts | 17 +++- 6 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 src/main/daemon/daemon-legacy-exit-proof.ts create mode 100644 src/main/daemon/daemon-pty-adapter-subscription-fanout.test.ts create mode 100644 src/main/daemon/daemon-pty-router-legacy-gone.test.ts diff --git a/src/main/daemon/daemon-legacy-exit-proof.ts b/src/main/daemon/daemon-legacy-exit-proof.ts new file mode 100644 index 000000000000..bca241f5f20b --- /dev/null +++ b/src/main/daemon/daemon-legacy-exit-proof.ts @@ -0,0 +1,26 @@ +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { inspectProcessSignal } from './daemon-process-inspection' + +/** Keeps the pid each legacy daemon last answered with; the client forgets it on disconnect. */ +export class LegacyDaemonExitProof { + private readonly pids = new WeakMap() + + record(adapters: readonly DaemonPtyAdapter[]): void { + for (const adapter of adapters) { + const pid = + typeof adapter.getDaemonIdentity === 'function' ? adapter.getDaemonIdentity()?.pid : null + if (typeof pid === 'number') { + this.pids.set(adapter, pid) + } + } + } + + // Why: a refused or missing socket is loss of contact, not death; only a vanished pid proves the + // daemon exited. Windows is excluded like probeDaemonProcessIdentity, so it keeps failing closed. + hasExited(adapter: DaemonPtyAdapter): boolean { + const pid = this.pids.get(adapter) + return ( + process.platform !== 'win32' && pid !== undefined && inspectProcessSignal(pid) === 'missing' + ) + } +} diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.test.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.test.ts new file mode 100644 index 000000000000..e9348324f6be --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' +import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' + +function fakeAdapter(): { + adapter: DaemonPtyAdapter + emitData: (payload: DaemonPtyRouterDataEvent) => void + emitExit: (payload: DaemonPtyRouterExitEvent) => void + unsubscribed: () => number +} { + const dataListeners = new Set<(payload: DaemonPtyRouterDataEvent) => void>() + const exitListeners = new Set<(payload: DaemonPtyRouterExitEvent) => void>() + let unsubscribed = 0 + const release = (listeners: { clear: () => void }): (() => void) => { + return () => { + listeners.clear() + unsubscribed += 1 + } + } + return { + unsubscribed: () => unsubscribed, + emitData: (payload) => { + for (const listener of dataListeners) { + listener(payload) + } + }, + emitExit: (payload) => { + for (const listener of exitListeners) { + listener(payload) + } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fanout only subscribes through onData/onExit. + adapter: { + onData: (callback) => { + dataListeners.add(callback) + return release(dataListeners) + }, + onExit: (callback) => { + exitListeners.add(callback) + return release(exitListeners) + } + } as DaemonPtyAdapter + } +} + +describe('DaemonPtyAdapterSubscriptionFanout.dropAdapter', () => { + it('unsubscribes a dropped adapter so later data and exit events are not forwarded', () => { + const dropped = fakeAdapter() + const kept = fakeAdapter() + const onAdapterExit = vi.fn() + const fanout = new DaemonPtyAdapterSubscriptionFanout( + [dropped.adapter, kept.adapter], + onAdapterExit + ) + const data: string[] = [] + const exits: string[] = [] + fanout.onData((payload) => data.push(payload.data)) + fanout.onExit((payload) => exits.push(payload.id)) + + dropped.emitData({ id: 'gone', data: 'before' }) + fanout.dropAdapter(dropped.adapter) + dropped.emitData({ id: 'gone', data: 'after' }) + dropped.emitExit({ id: 'gone', code: 0 }) + kept.emitData({ id: 'live', data: 'kept' }) + kept.emitExit({ id: 'live', code: 0 }) + + expect(dropped.unsubscribed()).toBe(2) + expect(data).toEqual(['before', 'kept']) + expect(exits).toEqual(['live']) + expect(onAdapterExit).toHaveBeenCalledTimes(1) + expect(onAdapterExit).toHaveBeenCalledWith('live') + + fanout.dispose() + expect(dropped.unsubscribed()).toBe(2) + expect(kept.unsubscribed()).toBe(2) + }) +}) diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts index 1d82ef70b7ba..c1ac94364082 100644 --- a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts @@ -5,16 +5,20 @@ import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemo export class DaemonPtyAdapterSubscriptionFanout { private unsubscribers: (() => void)[] = [] + private readonly adapterUnsubscribers = new Map void)[]>() private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = [] private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = [] + private readonly adapters: DaemonPtyAdapter[] + constructor( - private readonly adapters: readonly DaemonPtyAdapter[], + adapters: readonly DaemonPtyAdapter[], onAdapterExit: (id: string) => void, onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void ) { - for (const adapter of adapters) { - this.unsubscribers.push( + this.adapters = [...adapters] + for (const adapter of this.adapters) { + const unsubscribers = [ adapter.onData((payload) => { for (const listener of this.dataListeners) { listener(payload) @@ -29,7 +33,28 @@ export class DaemonPtyAdapterSubscriptionFanout { ...(onAdapterIdentityChanged && typeof adapter.onDaemonIdentityChanged === 'function' ? [adapter.onDaemonIdentityChanged(() => onAdapterIdentityChanged(adapter))] : []) - ) + ] + this.adapterUnsubscribers.set(adapter, unsubscribers) + this.unsubscribers.push(...unsubscribers) + } + } + + dropAdapter(adapter: DaemonPtyAdapter): void { + const index = this.adapters.indexOf(adapter) + if (index !== -1) { + this.adapters.splice(index, 1) + } + const unsubscribers = this.adapterUnsubscribers.get(adapter) + if (!unsubscribers) { + return + } + this.adapterUnsubscribers.delete(adapter) + for (const unsubscribe of unsubscribers) { + unsubscribe() + const listed = this.unsubscribers.indexOf(unsubscribe) + if (listed !== -1) { + this.unsubscribers.splice(listed, 1) + } } } diff --git a/src/main/daemon/daemon-pty-router-legacy-gone.test.ts b/src/main/daemon/daemon-pty-router-legacy-gone.test.ts new file mode 100644 index 000000000000..61790feb9800 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-legacy-gone.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonPtyRouter } from './daemon-pty-router' + +const LIVE_PID = 4242 +const EXITED_PID = 4343 + +vi.mock('./daemon-process-inspection', () => ({ + inspectProcessSignal: (pid: number) => (pid === EXITED_PID ? 'missing' : 'occupied') +})) + +function adapter( + label: string, + sessions: string[] = [], + pid: number | null = EXITED_PID +): DaemonPtyAdapter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the router only calls these members on this path. + return { + listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), + disconnectOnly: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => + pid === null ? null : { pid, startedAtMs: 1, launchNonce: label } + ), + onData: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + onBackgroundStreamEvent: vi.fn(() => () => {}) + } as unknown as DaemonPtyAdapter +} + +function connectError(code: 'ECONNREFUSED' | 'ENOENT' | 'ECONNRESET', syscall: string): Error { + return Object.assign(new Error(`${syscall} ${code}`), { code, syscall }) +} + +describe('DaemonPtyRouter legacy socket that can never answer', () => { + // Windows keeps failing closed: the router never treats a missing pid as proof there. + it.skipIf(process.platform === 'win32')( + 'drops a refused or missing legacy socket and does not ask it again', + async () => { + const current = adapter('current', ['current-session']) + const refused = adapter('refused') + const missing = adapter('missing') + vi.mocked(refused.listProcesses).mockRejectedValue(connectError('ECONNREFUSED', 'connect')) + vi.mocked(missing.listProcesses).mockRejectedValue(connectError('ENOENT', 'connect')) + vi.mocked(refused.disconnectOnly).mockRejectedValue(new Error('already closed')) + const router = new DaemonPtyRouter({ current, legacy: [refused, missing] }) + const sessions = [{ id: 'current-session', cwd: '', title: 'current' }] + + await expect(router.listProcesses()).resolves.toEqual(sessions) + await expect(router.listProcesses()).resolves.toEqual(sessions) + + expect(refused.listProcesses).toHaveBeenCalledOnce() + expect(missing.listProcesses).toHaveBeenCalledOnce() + expect(refused.disconnectOnly).toHaveBeenCalledOnce() + expect(router.getLegacyAdapters()).toEqual([]) + + await router.discoverLegacySessions() + router.onBackgroundStreamEvent(() => {}) + expect(refused.listProcesses).toHaveBeenCalledOnce() + expect(missing.listProcesses).toHaveBeenCalledOnce() + expect(refused.onBackgroundStreamEvent).not.toHaveBeenCalled() + expect(current.onBackgroundStreamEvent).toHaveBeenCalledOnce() + } + ) + + it('keeps a gone legacy socket whose process is alive or unproven', async () => { + const current = adapter('current', ['current-session']) + const alive = adapter('alive', [], LIVE_PID) + const unknown = adapter('unknown', [], null) + vi.mocked(alive.listProcesses).mockRejectedValue(connectError('ENOENT', 'connect')) + vi.mocked(unknown.listProcesses).mockRejectedValue(connectError('ECONNREFUSED', 'connect')) + const router = new DaemonPtyRouter({ current, legacy: [alive, unknown] }) + + await expect(router.listProcesses()).rejects.toThrow('connect') + expect(router.getLegacyAdapters()).toEqual([alive, unknown]) + expect(alive.disconnectOnly).not.toHaveBeenCalled() + }) + + it('still fails closed for the current daemon and for any other legacy error', async () => { + const current = adapter('current', ['current-session']) + const legacy = adapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy unavailable')) + await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') + expect(router.getLegacyAdapters()).toEqual([legacy]) + + vi.mocked(legacy.listProcesses).mockRejectedValueOnce(connectError('ENOENT', 'open')) + await expect(router.listProcesses()).rejects.toThrow('open ENOENT') + expect(router.getLegacyAdapters()).toEqual([legacy]) + + vi.mocked(legacy.listProcesses).mockRejectedValueOnce(connectError('ECONNRESET', 'read')) + await expect(router.listProcesses()).rejects.toThrow('read ECONNRESET') + expect(router.getLegacyAdapters()).toEqual([legacy]) + + vi.mocked(current.listProcesses).mockRejectedValueOnce(connectError('ECONNREFUSED', 'connect')) + await expect(router.listProcesses()).rejects.toThrow('connect ECONNREFUSED') + expect(current.disconnectOnly).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 76698a68081c..a945871062f8 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -10,6 +10,8 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import { isDaemonEndpointGoneError } from './daemon-errors' +import { LegacyDaemonExitProof } from './daemon-legacy-exit-proof' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' @@ -24,6 +26,7 @@ export class DaemonPtyRouter implements IPtyProvider { private sessionAdapters = new Map() private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout + private readonly legacyExitProof = new LegacyDaemonExitProof() private readonly retirement = new DaemonRouterRetirement(() => this.allAdapters()) constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { @@ -37,10 +40,12 @@ export class DaemonPtyRouter implements IPtyProvider { }, (adapter) => this.ownerResolver.invalidateProvider(adapter) ) + this.legacyExitProof.record(this.legacy) } async discoverLegacySessions(): Promise { await this.ownerResolver.discoverRoutes() + this.legacyExitProof.record(this.legacy) } async spawn(opts: PtySpawnOptions): Promise { @@ -234,10 +239,37 @@ export class DaemonPtyRouter implements IPtyProvider { } async listProcesses(opts?: { deadlineMs?: number }): Promise { - // Why: runtime exact-stop/liveness flows must fail closed if any adapter - // cannot provide a trustworthy process list. + // Why: worktree removal fails closed on this list. A legacy daemon whose socket is gone and + // whose process has exited is an empty inventory; anything else still throws. const results = await Promise.all( - this.allAdapters().map((adapter) => adapter.listProcesses(opts)) + this.allAdapters().map(async (adapter) => { + try { + const processes = await adapter.listProcesses(opts) + this.legacyExitProof.record(this.legacy) + return processes + } catch (error) { + if ( + adapter === this.current || + !isDaemonEndpointGoneError(error) || + !this.legacyExitProof.hasExited(adapter) + ) { + throw error + } + const index = this.legacy.indexOf(adapter) + if (index !== -1) { + this.legacy.splice(index, 1) + // Why: the resolver and fanout were built from a copy of allAdapters(). + // Splicing this.legacy alone leaves them querying the dead socket. + this.ownerResolver.dropProvider(adapter) + this.subscriptions.dropAdapter(adapter) + } + // Why: the socket is already gone; a failed disconnect must not fail the list. + void Promise.resolve() + .then(() => adapter.disconnectOnly()) + .catch(() => undefined) + return [] + } + }) ) return results.flat() } @@ -305,7 +337,7 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current } - // Why by reference: legacy is set once in the constructor and never mutated; callers (Manage Sessions, restart swap) only read it. + // Why by reference: callers read these by identity. A gone legacy socket is spliced out in place; snapshot holders (the restart swap) re-check on their next list. getLegacyAdapters(): readonly DaemonPtyAdapter[] { return this.legacy } diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts index 47fe531ec7fd..ab4df3f484e2 100644 --- a/src/main/daemon/daemon-session-owner-resolution.ts +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -36,10 +36,23 @@ export class DaemonSessionOwnerResolver { private readonly routeIncarnations = new Map() private epoch = 0 + private readonly providers: T[] + constructor( - private readonly providers: readonly T[], + providers: readonly T[], private readonly routes: Map - ) {} + ) { + this.providers = [...providers] + } + + /** Removes a provider that can no longer be contacted, including from later inventories. */ + dropProvider(provider: T): void { + const index = this.providers.indexOf(provider) + if (index !== -1) { + this.providers.splice(index, 1) + } + this.invalidateProvider(provider) + } invalidateProvider(provider: T): void { this.epoch += 1 From e2339f27a5661e46c7115bdfed7253c3eb6858aa Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:55:18 -0700 Subject: [PATCH 51/58] fix(daemon): count a claimed agent resume as automatic even without a history seed (#27442) Co-authored-by: m4air --- src/main/daemon/terminal-spawn-gate.test.ts | 25 +++++++++++++++++++++ src/shared/host-spawn-admission.test.ts | 7 +++--- src/shared/host-spawn-admission.ts | 6 +++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/daemon/terminal-spawn-gate.test.ts b/src/main/daemon/terminal-spawn-gate.test.ts index 9ac777568659..2cb0c4e1e8d5 100644 --- a/src/main/daemon/terminal-spawn-gate.test.ts +++ b/src/main/daemon/terminal-spawn-gate.test.ts @@ -105,6 +105,31 @@ describe('TerminalSpawnGate', () => { ) }) + it('counts a remote resume that arrives with a claim but no history seed', async () => { + await host.createOrAttach({ + sessionId: 'remote-resume', + cols: 80, + rows: 24, + agentSessionEnsure: { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'r'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: 'tab-remote', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_remote' + } + }, + streamClient: stream() + }) + expect(gate.snapshot().liveAutomatic).toBe(1) + }) + it('never charges an attach and frees the live slot when the session exits', async () => { await restore('restore-a') await host.createOrAttach({ diff --git a/src/shared/host-spawn-admission.test.ts b/src/shared/host-spawn-admission.test.ts index 934d8421f35e..bf1a78ecd644 100644 --- a/src/shared/host-spawn-admission.test.ts +++ b/src/shared/host-spawn-admission.test.ts @@ -47,12 +47,11 @@ function mulberry32(seed: number): () => number { } describe('resolveSpawnCharge', () => { - it('infers automatic restore for an old caller only from a claim plus a history seed', () => { + it('infers automatic restore for an old caller from an agent claim, seeded or not', () => { const state = createSpawnAdmissionState(LIMITS, 0) expect(resolveSpawnCharge(state, facts(), LIMITS, 0)).toBe('automatic') - expect(resolveSpawnCharge(state, facts({ hasHistorySeed: false }), LIMITS, 0)).toBe( - 'interactive' - ) + // A remote client's resume: fresh terminal id, claim, no history seed. + expect(resolveSpawnCharge(state, facts({ hasHistorySeed: false }), LIMITS, 0)).toBe('automatic') expect(resolveSpawnCharge(state, facts({ hasAgentSessionClaim: false }), LIMITS, 0)).toBe( 'interactive' ) diff --git a/src/shared/host-spawn-admission.ts b/src/shared/host-spawn-admission.ts index 7fa693282568..7cf02ae74b77 100644 --- a/src/shared/host-spawn-admission.ts +++ b/src/shared/host-spawn-admission.ts @@ -133,9 +133,11 @@ export function resolveSpawnCharge( limits: SpawnAdmissionLimits, now: number ): SpawnCharge { + // Why a claim alone: a remote client's `--resume` arrives on a fresh terminal id with no + // history seed, so requiring one let every reconnect storm through. An old caller is only + // ever paced, so a user-started agent costs at most a short wait during a storm. const intent = - facts.declaredIntent ?? - (facts.hasAgentSessionClaim && facts.hasHistorySeed ? 'automatic-restore' : 'interactive') + facts.declaredIntent ?? (facts.hasAgentSessionClaim ? 'automatic-restore' : 'interactive') if (intent === 'interactive') { return 'interactive' } From ff5be7f7b68dd09167355ddf723ec85a62151bf2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:55:21 -0700 Subject: [PATCH 52/58] fix(runtime): reuse cached worktree rows when the admin probe cannot answer (#27445) * fix(runtime): reuse cached worktree rows when the admin probe cannot answer * test(runtime): wait for the fingerprint probe to settle instead of a fixed delay --------- Co-authored-by: m4air --- ...orca-runtime-refresh-repo-worktree-scan.ts | 78 ++++++++++++++++--- .../worktree-scan-cache-ttl.spec.ts | 13 +++- ...rktree-scan-admin-fingerprint-gate.test.ts | 51 +++++++++--- 3 files changed, 119 insertions(+), 23 deletions(-) diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index b4853ba70f11..afe0c752a10f 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -20,6 +20,25 @@ import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host' +const lastFingerprintUnknownWarnAt = new Map() + +// Why once per reconcile window: a wedged mount answers "unknown" on every poll. +function warnFingerprintUnknownOnce(repoId: string, reason: string): void { + const now = Date.now() + const last = lastFingerprintUnknownWarnAt.get(repoId) + if (last !== undefined && now - last < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS) { + return + } + lastFingerprintUnknownWarnAt.set(repoId, now) + console.warn( + '[worktree-scan] admin fingerprint unknown; reusing cached rows until the reconcile ceiling', + { + repoId, + reason + } + ) +} + export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget { /** * Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap @@ -48,28 +67,61 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS ? cached : null - if (probe && reusable) { + // Why reuse on "cannot tell": a probe that is still pending or timed out says nothing about + // the repo, and turning that into a full scan is the feedback loop of #19312 (slow mount → + // timeouts → scans → more load). The original `scannedAt` is kept, so the reconcile ceiling + // still forces a real scan on schedule. + const reuseCached = (): RuntimeWorktreeScanRefresh | null => + reusable + ? { + result: reusable.result, + adminFingerprint: reusable.adminFingerprint, + adminFingerprintProbe: null, + scannedAt: reusable.scannedAt + } + : null + if (probe === 'in-flight') { + const reused = reuseCached() + if (reused) { + warnFingerprintUnknownOnce(repo.id, 'previous probe still pending') + return reused + } + } else if (probe && reusable) { // Why await only here: this is the one branch whose decision needs the probe. A scan-bound // caller must never wait on it, or every cold read pays filesystem latency it cannot use. const probed = await withTimeoutResult(probe, WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) if (!probed.ok) { - // Why log: expiry and "fingerprint unavailable" both surface as `null`, so a wedged mount is - // otherwise indistinguishable from a repo that simply cannot be fingerprinted. - console.warn('[worktree-scan] admin fingerprint probe expired; running a full scan', { - repoId: repo.id, - timeoutMs: WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS - }) + warnFingerprintUnknownOnce(repo.id, 'probe expired') + return ( + reuseCached() ?? + (await this.scanRepoWorktreesForRefresh(repo, projectRuntime, null, scannedAt)) + ) } - const current = probed.ok ? probed.value : null - if (current !== null && current === reusable.adminFingerprint) { + // A known fingerprint that matches proves nothing changed; null means the repo cannot be + // fingerprinted, which (like a mismatch) needs a real scan. + if (probed.value !== null && probed.value === reusable.adminFingerprint) { return { result: reusable.result, - adminFingerprint: current, + adminFingerprint: probed.value, adminFingerprintProbe: null, scannedAt: reusable.scannedAt } } } + return await this.scanRepoWorktreesForRefresh( + repo, + projectRuntime, + probe === 'in-flight' ? null : probe, + scannedAt + ) + } + + private async scanRepoWorktreesForRefresh( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + probe: Promise | null, + scannedAt: number + ): Promise { const result = dropRepeatedWorktreeRows( await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) ) @@ -81,9 +133,11 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK * Why the gate: `withTimeout` abandons a probe without cancelling it, and readdir/stat take no * AbortSignal — on a wedged mount a fresh probe per refresh would pin every libuv fs thread. */ - protected startRepoWorktreeAdminFingerprintProbe(repo: Repo): Promise | null { + protected startRepoWorktreeAdminFingerprintProbe( + repo: Repo + ): Promise | 'in-flight' { if (this.worktreeAdminFingerprintProbes.has(repo.id)) { - return null + return 'in-flight' } this.worktreeAdminFingerprintProbes.add(repo.id) return readRepoWorktreeAdminFingerprint(repo.path) diff --git a/src/main/runtime/orca-runtime-tests/worktree-scan-cache-ttl.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-scan-cache-ttl.spec.ts index f8ec37f7c2cd..fd614e0eeb0d 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-scan-cache-ttl.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-scan-cache-ttl.spec.ts @@ -46,7 +46,15 @@ describe('resolveWorktreeScanCacheTtlMs', () => { it('keeps a scratch repo scan cached past the base TTL while normal repos rescan', async () => { // Why: the whole fix lives in the cache-stamp call site; pin the wiring so // a revert to the flat TTL fails CI, not just the pure-function tests. - vi.useFakeTimers() + // Why only Date: each refresh starts a real admin-fingerprint read, which must settle on the + // real event loop; a probe still pending at the next refresh reuses the cache by design. + vi.useFakeTimers({ toFake: ['Date'] }) + let runtimeProbes: { size: number } = { size: 0 } + const settleFingerprintProbes = async (): Promise => { + for (let attempt = 0; attempt < 400 && runtimeProbes.size > 0; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + } // Why: the shared listWorktrees stub keeps call history across this file's // tests; absolute counts need a clean baseline. vi.mocked(listWorktrees).mockClear() @@ -66,10 +74,13 @@ describe('resolveWorktreeScanCacheTtlMs', () => { ] } as never) const internals = runtime as unknown as { listResolvedWorktrees: () => Promise } + // Why bracket access: the probe set is protected runtime state this test only reads. + runtimeProbes = runtime['worktreeAdminFingerprintProbes'] const scanCallsFor = (path: string): number => vi.mocked(listWorktrees).mock.calls.filter((call) => call[0] === path).length await internals.listResolvedWorktrees() + await settleFingerprintProbes() expect(scanCallsFor('/tmp/repo')).toBe(1) expect(scanCallsFor(scratchPath)).toBe(1) diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 7dfe01444061..054122751680 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -403,8 +403,9 @@ describe('worktree scan admin-fingerprint gate', () => { const secondSettled = trackSettled(second) await drainMicrotasks() expect(secondSettled()).toBe(true) - await second - expect(scanCount()).toBe(2) + // #19312: a pending probe says nothing, so it reuses the scanned rows instead of rescanning. + expect(heads(await second)).toEqual(['abc', 'def']) + expect(scanCount()).toBe(1) vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) const third = list() @@ -412,7 +413,7 @@ describe('worktree scan admin-fingerprint gate', () => { await drainMicrotasks() expect(thirdSettled()).toBe(true) await third - expect(scanCount()).toBe(3) + expect(scanCount()).toBe(1) } finally { vi.useRealTimers() } @@ -427,6 +428,7 @@ describe('worktree scan admin-fingerprint gate', () => { await list() expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1) + const startedAt = Date.now() for (let refresh = 0; refresh < 20; refresh += 1) { vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) await list() @@ -434,7 +436,12 @@ describe('worktree scan admin-fingerprint gate', () => { // Each abandoned probe pins an fs work item on the 4-slot libuv pool forever. expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1) - expect(scanCount()).toBe(21) + // Only the reconcile ceiling rescans while the probe stays pending, never every refresh. + const elapsed = Date.now() - startedAt + expect(scanCount()).toBeGreaterThan(1) + expect(scanCount()).toBeLessThanOrEqual( + 1 + Math.ceil(elapsed / WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS) + ) } finally { vi.useRealTimers() } @@ -449,24 +456,24 @@ describe('worktree scan admin-fingerprint gate', () => { await list() vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) await list() - expect(scanCount()).toBe(2) + expect(scanCount()).toBe(1) // The mount unwedges; its stale answer must not gate anything, but the repo must probe again. releaseStalledProbe('fp-stale') await drainMicrotasks() vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) await list() - expect(scanCount()).toBe(3) + expect(scanCount()).toBe(2) vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) await list() - expect(scanCount()).toBe(3) + expect(scanCount()).toBe(2) } finally { vi.useRealTimers() } }) - it('scans when the awaited probe outlives its deadline', async () => { + it('reuses the scanned rows when the awaited probe outlives its deadline', async () => { vi.useFakeTimers() try { const { list } = makeRuntime() @@ -485,8 +492,32 @@ describe('worktree scan admin-fingerprint gate', () => { await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) await drainMicrotasks() + // #19312: a timeout is "unknown", never a mismatch, so it must not start a full scan. + expect(scanCount()).toBe(1) + expect(heads(await second)).toEqual(['abc', 'def']) + } finally { + vi.useRealTimers() + } + }) + + it('still reconciles on schedule while every probe times out', async () => { + vi.useFakeTimers() + try { + readRepoWorktreeAdminFingerprintMock.mockImplementation(() => new Promise(() => {})) + const { list } = makeRuntime() + const scannedAt = Date.now() + await list() + expect(scanCount()).toBe(1) + + while (Date.now() - scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS - SCAN_TTL_MS) { + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + } + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(2 * SCAN_TTL_MS) + await list() expect(scanCount()).toBe(2) - await second } finally { vi.useRealTimers() } @@ -537,7 +568,7 @@ describe('worktree scan admin-fingerprint gate', () => { await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) await drainMicrotasks() expect(secondSettled()).toBe(true) - expect(scanCount()).toBe(2) + expect(scanCount()).toBe(1) expect(heads(await second)).toEqual(['abc', 'def']) } finally { vi.useRealTimers() From 616e9c3e3af04b748a74368760d0193abef21ba4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:55:24 -0700 Subject: [PATCH 53/58] refactor(cli): extract the serve supervisor's after-exit decision into a pure policy (#27446) * refactor(cli): extract the serve supervisor's after-exit decision into a pure policy * refactor(cli): type the exit signal without a child_process import --------- Co-authored-by: m4air --- .../runtime/serve-supervisor-policy.test.ts | 90 +++++++++++++++++++ src/cli/runtime/serve-supervisor-policy.ts | 63 +++++++++++++ src/cli/runtime/serve-update-supervisor.ts | 63 +++++++------ 3 files changed, 187 insertions(+), 29 deletions(-) create mode 100644 src/cli/runtime/serve-supervisor-policy.test.ts create mode 100644 src/cli/runtime/serve-supervisor-policy.ts diff --git a/src/cli/runtime/serve-supervisor-policy.test.ts b/src/cli/runtime/serve-supervisor-policy.test.ts new file mode 100644 index 000000000000..2c12a9611e04 --- /dev/null +++ b/src/cli/runtime/serve-supervisor-policy.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { decideAfterServeChildExit, type ServeChildExit } from './serve-supervisor-policy' + +const HANDOFF = { + schemaVersion: 1 as const, + phase: 'install-requested' as const, + fromVersion: '1.0.0', + targetVersion: '1.1.0', + servingPid: 42 +} + +function exit(overrides: Partial = {}): ServeChildExit { + return { + code: 0, + signal: null, + readiness: 'not-expected', + signalWasForwarded: false, + childPid: 42, + ...overrides + } +} + +describe('decideAfterServeChildExit', () => { + it('passes a normal exit code through, and explains an unforwarded signal', () => { + const base = { expectedHandoff: null, handoff: null, hasHandoffPath: true } + expect(decideAfterServeChildExit({ ...base, exit: exit({ code: 3 }) })).toEqual({ + kind: 'exit', + code: 3 + }) + expect( + decideAfterServeChildExit({ + ...base, + exit: exit({ code: null, signal: 'SIGTERM', signalWasForwarded: true }) + }) + ).toEqual({ kind: 'exit', code: 0 }) + expect( + decideAfterServeChildExit({ ...base, exit: exit({ code: null, signal: 'SIGABRT' }) }) + ).toEqual({ kind: 'signal-exit', signal: 'SIGABRT' }) + }) + + it('waits for the new bundle only when this very child requested the install', () => { + expect( + decideAfterServeChildExit({ + exit: exit(), + expectedHandoff: null, + handoff: HANDOFF, + hasHandoffPath: true + }) + ).toEqual({ kind: 'await-install', handoff: HANDOFF }) + expect( + decideAfterServeChildExit({ + exit: exit({ childPid: 7 }), + expectedHandoff: null, + handoff: HANDOFF, + hasHandoffPath: true + }) + ).toEqual({ kind: 'exit', code: 0 }) + expect( + decideAfterServeChildExit({ + exit: exit(), + expectedHandoff: null, + handoff: HANDOFF, + hasHandoffPath: false + }) + ).toEqual({ kind: 'exit', code: 0 }) + }) + + it('fails an update whose replacement exited before serving the target version', () => { + expect( + decideAfterServeChildExit({ + exit: exit({ readiness: 'pending' }), + expectedHandoff: HANDOFF, + handoff: null, + hasHandoffPath: true + }) + ).toEqual({ + kind: 'fail-replacement', + handoff: HANDOFF, + reason: 'Replacement exited before serving version 1.1.0.' + }) + expect( + decideAfterServeChildExit({ + exit: exit({ readiness: 'failed' }), + expectedHandoff: HANDOFF, + handoff: null, + hasHandoffPath: true + }) + ).toEqual({ kind: 'exit', code: 1 }) + }) +}) diff --git a/src/cli/runtime/serve-supervisor-policy.ts b/src/cli/runtime/serve-supervisor-policy.ts new file mode 100644 index 000000000000..c709fa6c76b7 --- /dev/null +++ b/src/cli/runtime/serve-supervisor-policy.ts @@ -0,0 +1,63 @@ +import type { ProcessResult } from '@orca/process-host/process-spec' +import type { ServeUpdateHandoffState } from '../../shared/serve-update-handoff' + +type ExitSignal = ProcessResult['signal'] + +export type InstallRequestedHandoff = Extract< + ServeUpdateHandoffState, + { phase: 'install-requested' } +> +export type ServeReadiness = 'not-expected' | 'pending' | 'verified' | 'failed' + +export type ServeChildExit = { + code: number | null + signal: ExitSignal + readiness: ServeReadiness + signalWasForwarded: boolean + childPid: number | undefined +} + +export type ServeSupervisorDecision = + /** The replacement failed its readiness check; already recorded on the handoff. */ + | { kind: 'exit'; code: number } + /** A replacement for an update exited before serving its version. */ + | { kind: 'fail-replacement'; handoff: InstallRequestedHandoff; reason: string } + /** The child died by a signal nobody forwarded; explain it instead of exiting quietly. */ + | { kind: 'signal-exit'; signal: ExitSignal } + /** This child asked to install an update: wait for the new bundle, then serve from it. */ + | { kind: 'await-install'; handoff: InstallRequestedHandoff } + +/** + * What the CLI supervisor does after its serve child exits. Pure, so the restart rules can be + * model-tested apart from process and file I/O; the loop in serve-update-supervisor only acts. + */ +export function decideAfterServeChildExit(input: { + exit: ServeChildExit + expectedHandoff: InstallRequestedHandoff | null + /** The handoff on disk after the exit; null when there is none or no handoff path. */ + handoff: ServeUpdateHandoffState | null + hasHandoffPath: boolean +}): ServeSupervisorDecision { + const { exit, expectedHandoff, handoff } = input + if (exit.readiness === 'failed') { + return { kind: 'exit', code: 1 } + } + if (expectedHandoff && exit.readiness !== 'verified') { + return { + kind: 'fail-replacement', + handoff: expectedHandoff, + reason: `Replacement exited before serving version ${expectedHandoff.targetVersion}.` + } + } + if ( + !input.hasHandoffPath || + handoff?.phase !== 'install-requested' || + (exit.childPid !== undefined && handoff.servingPid !== exit.childPid) + ) { + if (typeof exit.code === 'number' || exit.signalWasForwarded) { + return { kind: 'exit', code: exit.code ?? 0 } + } + return { kind: 'signal-exit', signal: exit.signal } + } + return { kind: 'await-install', handoff } +} diff --git a/src/cli/runtime/serve-update-supervisor.ts b/src/cli/runtime/serve-update-supervisor.ts index 648e3508061e..451448321d67 100644 --- a/src/cli/runtime/serve-update-supervisor.ts +++ b/src/cli/runtime/serve-update-supervisor.ts @@ -12,6 +12,11 @@ import { } from '../../shared/quit-teardown-deadline' import { serveSignalExitError } from './serve-signal-exit-diagnostic' import { waitForMacBundleVersion } from './mac-app-update-bundle' +import { + decideAfterServeChildExit, + type InstallRequestedHandoff, + type ServeReadiness +} from './serve-supervisor-policy' export const SERVE_REPLACEMENT_READY_TIMEOUT_MS = 60_000 export const SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS = 5_000 @@ -20,9 +25,6 @@ export const SERVE_CHILD_FORCE_KILL_GRACE_MS = WILL_QUIT_TEARDOWN_DEADLINE_MS + SERVE_CHILD_FORCE_KILL_SCHEDULING_MARGIN_MS -type InstallRequestedHandoff = Extract -type ServeReadiness = 'not-expected' | 'pending' | 'verified' | 'failed' - type ServeSupervisorArgs = { executable: string childArgs: string[] @@ -75,35 +77,38 @@ export async function superviseForegroundServe( ? { handoffPath: args.handoffPath, handoff: expectedHandoff } : null ) - - if (result.readiness === 'failed') { - return 1 - } - if (expectedHandoff && result.readiness !== 'verified') { - if (args.handoffPath) { - await recordServeUpdateHandoffFailure( + const decision = decideAfterServeChildExit({ + exit: { ...result, childPid: child.pid }, + expectedHandoff, + handoff: + result.readiness === 'failed' || (expectedHandoff && result.readiness !== 'verified') + ? null + : args.handoffPath + ? await readServeUpdateHandoff(args.handoffPath) + : null, + hasHandoffPath: args.handoffPath !== null + }) + switch (decision.kind) { + case 'exit': + return decision.code + case 'fail-replacement': + if (args.handoffPath) { + await recordServeUpdateHandoffFailure(args.handoffPath, decision.handoff, decision.reason) + } + return 1 + case 'signal-exit': + throw serveSignalExitError(decision.signal) + case 'await-install': + if (!args.handoffPath) { + return 1 + } + expectedHandoff = await awaitInstalledHandoff( + args.executable, args.handoffPath, - expectedHandoff, - `Replacement exited before serving version ${expectedHandoff.targetVersion}.` + decision.handoff ) - } - return 1 + child = args.spawnChild(args.executable, args.childArgs, args.spawnOptions) } - - const handoff = args.handoffPath ? await readServeUpdateHandoff(args.handoffPath) : null - if ( - !args.handoffPath || - handoff?.phase !== 'install-requested' || - (child.pid !== undefined && handoff.servingPid !== child.pid) - ) { - if (typeof result.code === 'number' || result.signalWasForwarded) { - return result.code ?? 0 - } - throw serveSignalExitError(result.signal) - } - - expectedHandoff = await awaitInstalledHandoff(args.executable, args.handoffPath, handoff) - child = args.spawnChild(args.executable, args.childArgs, args.spawnOptions) } } From 061cb226e22a11bd24f02c60a803045ffc28701c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 03:58:58 -0700 Subject: [PATCH 54/58] refactor(orcad): shared serve-option validation; no daemon barrel (#27443) orcad now checks its flags with the same validator Electron serve and the CLI use, so contradictory pairing flags fail with the same message instead of one silently winning. The CLI launcher drops its duplicate recipe check (the CLI handler already ran the shared validator). Removes the daemon-init and cli/runtime/index re-export barrels and the parsePtySessionId / OrcadInstanceLockError re-exports; callers and mocks import the defining modules. Co-authored-by: m4air Co-authored-by: Claude Opus 5.5 --- .../scripts/verify-packaged-daemon-entry.cjs | 2 +- src/cli/runtime-client.ts | 13 +++---- src/cli/runtime/index.ts | 10 ------ src/cli/runtime/launch.ts | 3 -- src/main/daemon/daemon-init-fresh-import.ts | 8 ++++- .../daemon-init-wedged-daemon-grace.test.ts | 2 +- src/main/daemon/daemon-init.ts | 23 ------------ .../daemon/daemon-pty-process-inspection.ts | 2 +- .../daemon/daemon-pty-session-inventory.ts | 2 +- src/main/daemon/pty-session-id.test.ts | 2 +- src/main/daemon/pty-session-id.ts | 6 ---- .../foreground-process-tracker.ts | 2 +- src/main/daemon/wsl-session-context.ts | 2 +- src/main/ipc/pty-management.test.ts | 6 ++-- src/main/ipc/pty-management.ts | 6 ++-- .../local-builds/local-build-compatibility.ts | 2 +- ...cal-pty-registry-folder-workspaces.test.ts | 2 +- .../memory/hydrate-local-pty-registry.test.ts | 2 +- src/main/memory/hydrate-local-pty-registry.ts | 2 +- src/main/orcad/orcad-command-arguments.ts | 15 +++++--- .../orcad/orcad-daemon-retirement.test.ts | 2 +- src/main/orcad/orcad-daemon-retirement.ts | 2 +- .../orcad/orcad-daemon-supervision.test.ts | 6 ++-- src/main/orcad/orcad-daemon-supervision.ts | 4 +-- src/main/orcad/orcad-entry.ts | 2 +- src/main/orcad/orcad-exit-code.ts | 2 +- src/main/orcad/orcad-health.test.ts | 2 +- src/main/orcad/orcad-health.ts | 2 +- src/main/orcad/orcad-instance-lock.test.ts | 2 +- src/main/orcad/orcad-instance-lock.ts | 2 -- src/main/orcad/orcad-launch-contract.test.ts | 35 +++++++++++-------- .../orcad/orcad-managed-idle-exit-host.ts | 2 +- src/main/orcad/orcad-push-startup.test.ts | 2 +- .../desktop-profile-instance-lock.test.ts | 3 +- .../startup/desktop-profile-instance-lock.ts | 7 ++-- src/main/startup/main-process-pty-startup.ts | 7 ++-- .../main-process-quit-pending-writer.test.ts | 2 +- .../main-process-quit-update-veto.test.ts | 2 +- src/main/startup/main-process-quit.ts | 2 +- .../startup/main-process-runtime-service.ts | 2 +- src/shared/daemon-lifecycle-telemetry.ts | 2 +- .../daemon-generation-desktop-discovery.ts | 2 +- .../tools/win-update-e2e/daemon-processes.mjs | 2 +- 43 files changed, 88 insertions(+), 120 deletions(-) delete mode 100644 src/cli/runtime/index.ts delete mode 100644 src/main/daemon/daemon-init.ts diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs index 8f5e802825c3..006f5715012e 100644 --- a/config/scripts/verify-packaged-daemon-entry.cjs +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -4,7 +4,7 @@ const { join } = require('node:path') // Why: `asarUnpack` in config/electron-builder.config.cjs lists // out/main/daemon-entry.js on every platform, and the packaged daemon fork -// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A +// (src/main/daemon/daemon-launch-paths.ts) resolves exactly this unpacked path. A // missing entry means the package layout regressed, so the check throws // instead of skipping — a silent skip false-passed exactly the layout bug // this gate exists to catch. diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts index f88b116981b6..981759a005a4 100644 --- a/src/cli/runtime-client.ts +++ b/src/cli/runtime-client.ts @@ -1,14 +1,11 @@ -// Why: the runtime client used to live here as a single file. It was split -// into ./runtime/{types,metadata,transport,status,launch,client}.ts so each -// concern can be tested in isolation. This barrel preserves the original -// import surface so call sites (src/cli/index.ts, tests) remain unchanged. +// Why: one import surface (and one vi.mock target) for CLI handlers over ./runtime/*. +export { RuntimeClient } from './runtime/client' +export { serveOrcaApp } from './runtime/launch' +export { getDefaultUserDataPath } from './runtime/metadata' export { - RuntimeClient, RuntimeClientError, RuntimeRpcFailureError, - serveOrcaApp, - getDefaultUserDataPath, type RuntimeRpcFailure, type RuntimeRpcResponse, type RuntimeRpcSuccess -} from './runtime/index' +} from './runtime/types' diff --git a/src/cli/runtime/index.ts b/src/cli/runtime/index.ts deleted file mode 100644 index 81d9fdf16c6f..000000000000 --- a/src/cli/runtime/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { RuntimeClient } from './client' -export { serveOrcaApp } from './launch' -export { getDefaultUserDataPath } from './metadata' -export { - RuntimeClientError, - RuntimeRpcFailureError, - type RuntimeRpcFailure, - type RuntimeRpcResponse, - type RuntimeRpcSuccess -} from './types' diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index a4d24de8bd2a..0b88807f8277 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -108,9 +108,6 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions): export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise { const executable = resolveForegroundOrcaExecutable() - if (args.recipeJson && !args.projectRoot) { - throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.') - } // Why one value: the selector, orcad and Electron must all serve the profile the caller chose. const userDataPath = resolveLaunchUserDataPath() // Why synchronous on the opt-out: it must spawn Electron exactly as before, without asking. diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index 39f3625c0636..d63815e88ee6 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -125,5 +125,11 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { getAppMetrics: () => [] } as unknown as AppEnvironment) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. - return import('./daemon-init') + const [init, restart, providerState, cleanup] = await Promise.all([ + import('./daemon-provider-init'), + import('./daemon-provider-restart'), + import('./daemon-provider-state'), + import('./daemon-protocol-cleanup') + ]) + return { ...init, ...restart, ...providerState, ...cleanup } } diff --git a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts index 9cd55946112e..3c0f9ef8e433 100644 --- a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts +++ b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { WEDGED_DAEMON_GRACE_RETRIES } from './daemon-init' +import { WEDGED_DAEMON_GRACE_RETRIES } from './daemon-replacement-preflight' import { FAKE_RUNTIME_DIR } from './daemon-init-test-harness' import { DAEMON_RECOVERY_BUDGET_MS, diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts deleted file mode 100644 index 6f0bd4eefc54..000000000000 --- a/src/main/daemon/daemon-init.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { createLegacyDaemonAdapters } from './daemon-legacy-adapters' -export { initDaemonPtyProvider } from './daemon-provider-init' -export { restartDaemon, type RestartDaemonResult } from './daemon-provider-restart' -export { - daemonOwnsFreshPersistentPtys, - disconnectDaemon, - getCurrentDaemonMacTccAttributionHealth, - getDaemonEndpointFacts, - getDaemonProvider, - listLiveDaemonPtyIds, - listLiveDaemonSessions, - requestIdleDaemonRetirement, - releaseDaemonRetirementFence, - readDaemonPidRecord, - replaceDaemonProvider, - shutdownDaemon, - type DaemonEndpointFacts -} from './daemon-provider-state' -export { - cleanupDaemonForProtocol, - type OrphanedDaemonCleanupResult -} from './daemon-protocol-cleanup' -export { WEDGED_DAEMON_GRACE_RETRIES } from './daemon-replacement-preflight' diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index 335c641da627..0c10a2a2c857 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -1,6 +1,6 @@ import { isShellProcess } from '../../shared/agent-detection' import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots' -import { parsePtySessionId } from './pty-session-id' +import { parsePtySessionId } from '../../shared/pty-session-id-format' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index 39d0bdafcce6..7f1a4f6cc47f 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -11,7 +11,7 @@ import { recordAuthenticatedInventory } from './daemon-audit-classifier' import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors' import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection' import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' -import { parsePtySessionId } from './pty-session-id' +import { parsePtySessionId } from '../../shared/pty-session-id-format' import type { ListSessionsResult, SessionInfo } from './types' import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' import type { PtyProcessInfo } from '../providers/types' diff --git a/src/main/daemon/pty-session-id.test.ts b/src/main/daemon/pty-session-id.test.ts index c8e7077c8a03..6d8f7c1b8ea9 100644 --- a/src/main/daemon/pty-session-id.test.ts +++ b/src/main/daemon/pty-session-id.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest' import { isSafePtySessionId, mintPtySessionId, - parsePtySessionId, ptySessionIdForAgentCreateOperation } from './pty-session-id' +import { parsePtySessionId } from '../../shared/pty-session-id-format' const USER_DATA = '/tmp/orca-userdata' diff --git a/src/main/daemon/pty-session-id.ts b/src/main/daemon/pty-session-id.ts index 69324104af68..dab9c6ab3c33 100644 --- a/src/main/daemon/pty-session-id.ts +++ b/src/main/daemon/pty-session-id.ts @@ -2,12 +2,6 @@ import { randomUUID } from 'node:crypto' import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { PTY_SESSION_ID_SEPARATOR } from '../../shared/pty-session-id-format' -// Why: re-exported here so main-side callers can keep importing -// `parsePtySessionId` from this module (next to `mintPtySessionId`). The -// implementation lives in `src/shared/` because the renderer-side merge -// helper also needs it and cannot import node-only modules. -export { parsePtySessionId } from '../../shared/pty-session-id-format' - /** * Session IDs use the format `${worktreeId}@@${shortUuid}` so that * DaemonPtyAdapter.reconcileOnStartup (see daemon-pty-adapter.ts) can diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 7a96877c204f..1a7277903c35 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -23,7 +23,7 @@ import { } from '../../../shared/foreground-wrapper-agent' import { isShellProcess } from '../../../shared/shell-process-detection' import { resolveFallbackForegroundProcess } from './foreground-fallback-process' -import { parsePtySessionId } from '../pty-session-id' +import { parsePtySessionId } from '../../../shared/pty-session-id-format' import { ptyProcessNameIsSpawnFile, createPtyForegroundResolver, diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index 0cf419872f9a..a594d6d93441 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -1,7 +1,7 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWslShellName } from '../../shared/local-windows-terminal-runtime' import { getDefaultWslDistro, parseWslPath } from '../wsl' -import { parsePtySessionId } from './pty-session-id' +import { parsePtySessionId } from '../../shared/pty-session-id-format' import { parseWslUncPath } from '../../shared/wsl-paths' export type WslSessionContext = { diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 0e46f2ab6bb9..17787a3c5a0e 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -45,9 +45,11 @@ vi.mock('../daemon/daemon-folder-access-reset', () => ({ resetFolderAccessForDaemon: resetFolderAccessForDaemonMock })) -vi.mock('../daemon/daemon-init', () => ({ +vi.mock('../daemon/daemon-provider-restart', () => ({ + restartDaemon: restartDaemonMock +})) +vi.mock('../daemon/daemon-provider-state', () => ({ getDaemonProvider: getDaemonProviderMock, - restartDaemon: restartDaemonMock, getCurrentDaemonMacTccAttributionHealth: getCurrentDaemonMacTccAttributionHealthMock })) diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 2503ced8bbd2..871545d8adf9 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -3,9 +3,9 @@ import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provide import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import { getCurrentDaemonMacTccAttributionHealth, - getDaemonProvider, - restartDaemon -} from '../daemon/daemon-init' + getDaemonProvider +} from '../daemon/daemon-provider-state' +import { restartDaemon } from '../daemon/daemon-provider-restart' import { getAllDaemonAdapters, getCurrentDaemonAdapter } from '../daemon/daemon-provider-routing' import { getDaemonFolderAccessMismatch, diff --git a/src/main/local-builds/local-build-compatibility.ts b/src/main/local-builds/local-build-compatibility.ts index 482d7a4f6434..f36fcf4678c5 100644 --- a/src/main/local-builds/local-build-compatibility.ts +++ b/src/main/local-builds/local-build-compatibility.ts @@ -4,7 +4,7 @@ import { type LocalBuildCompatibility } from '../../shared/local-build-compatibility' import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' -import { getDaemonProvider } from '../daemon/daemon-init' +import { getDaemonProvider } from '../daemon/daemon-provider-state' import { getAllDaemonAdapters } from '../daemon/daemon-provider-routing' import { getLocalPtyProvider } from '../ipc/pty' import { LocalPtyProvider } from '../providers/local-pty-provider' diff --git a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts b/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts index c67e4772beeb..2464817783dd 100644 --- a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts +++ b/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts @@ -4,7 +4,7 @@ import type { SessionInfo } from '../daemon/types' import type { Store } from '../persistence' const getDaemonProviderMock = vi.fn() -vi.mock('../daemon/daemon-init', () => ({ +vi.mock('../daemon/daemon-provider-state', () => ({ getDaemonProvider: () => getDaemonProviderMock() })) vi.mock('../project-runtime-git-options', () => ({ diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index a9e8e312a75a..02094b17ac3e 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -29,7 +29,7 @@ const LARGE_SESSION_COUNT = 150_000 // getter. Stubbing it lets us drive the offline / throwing / live paths // without spinning up real sockets. const getDaemonProviderMock = vi.fn() -vi.mock('../daemon/daemon-init', () => ({ +vi.mock('../daemon/daemon-provider-state', () => ({ getDaemonProvider: () => getDaemonProviderMock() })) diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index fa1c176728ac..9e4e97bceb2b 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -5,7 +5,7 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' -import { getDaemonProvider } from '../daemon/daemon-init' +import { getDaemonProvider } from '../daemon/daemon-provider-state' import { getAllDaemonAdapters, type DaemonProvider } from '../daemon/daemon-provider-routing' import type { SessionInfo } from '../daemon/types' import { isFolderWorkspaceIdForRepo } from '../ipc/worktrees/folder-workspace-model' diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts index 03e4eb0b1288..2f9ca1d4519c 100644 --- a/src/main/orcad/orcad-command-arguments.ts +++ b/src/main/orcad/orcad-command-arguments.ts @@ -1,6 +1,7 @@ import type { OrcadOptions } from './orcad-entry' import { DEFAULT_WS_PORT } from '../runtime/runtime-rpc/runtime-rpc-pairing-types' import { ORCAD_LOOPBACK_BIND_HOST } from './orcad-bind-address' +import { getServeOptionValidationError } from '../../shared/serve-option-validation' export const ORCAD_USAGE = `orcad — the Orca runtime served without Electron. @@ -82,11 +83,15 @@ export function parseArgs(argv: string[]): OrcadCommandArguments { throw new Error(`Unknown argument: ${arg}`) } } - if (options.grantDesktopControl && (options.noPairing || options.mobilePairing)) { - throw new Error('--grant-desktop-control applies only to the default runtime pairing offer') - } - if (options.recipeJson && !options.projectRoot) { - throw new Error('--recipe-json requires --project-root') + const validationError = getServeOptionValidationError({ + noPairing: options.noPairing === true, + mobilePairing: options.mobilePairing === true, + grantDesktopControl: options.grantDesktopControl, + recipeJson: options.recipeJson === true, + projectRoot: options.projectRoot + }) + if (validationError) { + throw new Error(validationError) } return options } diff --git a/src/main/orcad/orcad-daemon-retirement.test.ts b/src/main/orcad/orcad-daemon-retirement.test.ts index 3c952cf9b6a6..5414f4000c0e 100644 --- a/src/main/orcad/orcad-daemon-retirement.test.ts +++ b/src/main/orcad/orcad-daemon-retirement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -vi.mock('../daemon/daemon-init', () => ({ +vi.mock('../daemon/daemon-provider-state', () => ({ requestIdleDaemonRetirement: vi.fn(), listLiveDaemonSessions: vi.fn(), releaseDaemonRetirementFence: vi.fn() diff --git a/src/main/orcad/orcad-daemon-retirement.ts b/src/main/orcad/orcad-daemon-retirement.ts index 0cc73c99e520..70cc62745d94 100644 --- a/src/main/orcad/orcad-daemon-retirement.ts +++ b/src/main/orcad/orcad-daemon-retirement.ts @@ -9,7 +9,7 @@ import { listLiveDaemonSessions, releaseDaemonRetirementFence, requestIdleDaemonRetirement -} from '../daemon/daemon-init' +} from '../daemon/daemon-provider-state' import type { DaemonIdleRetirementResult } from '../daemon/daemon-pty-runtime-state' import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request' import { withTimeout } from '../../shared/promise-timeout-fallback' diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts index d301b715a9ea..562717c0783b 100644 --- a/src/main/orcad/orcad-daemon-supervision.test.ts +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -14,8 +14,10 @@ const { readDaemonPidRecordMock: vi.fn<() => { pid: number } | null>() })) -vi.mock('../daemon/daemon-init', () => ({ - initDaemonPtyProvider: initDaemonPtyProviderMock, +vi.mock('../daemon/daemon-provider-init', () => ({ + initDaemonPtyProvider: initDaemonPtyProviderMock +})) +vi.mock('../daemon/daemon-provider-state', () => ({ disconnectDaemon: disconnectDaemonMock, // Exported here purely so the test can prove it is never reached — killing the daemon on // orcad shutdown is what would make an orcad restart destructive again. diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts index 79ec4f3ed281..6a87db81f704 100644 --- a/src/main/orcad/orcad-daemon-supervision.ts +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -14,9 +14,9 @@ import { disconnectDaemon, daemonOwnsFreshPersistentPtys, - initDaemonPtyProvider, readDaemonPidRecord -} from '../daemon/daemon-init' +} from '../daemon/daemon-provider-state' +import { initDaemonPtyProvider } from '../daemon/daemon-provider-init' import { errorMessage } from '../../shared/error-message' const WINDOWS_DAEMON_STARTUP_TIMEOUT_MS = 30_000 diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 5d8eb854b7c0..9a04f5317318 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -169,7 +169,7 @@ async function startOrcadRuntime( const { buildOrcadServeReadiness } = await import('./orcad-serve-readiness') const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') - const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') + const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-provider-state') const { collectOrcadHealth } = await import('./orcad-health') // Why importable here: the singleton's module tree never reaches Electron, and orcad supplies // its persistence and endpoint paths explicitly below. diff --git a/src/main/orcad/orcad-exit-code.ts b/src/main/orcad/orcad-exit-code.ts index 6ae468fe9367..8259c5f7f5f3 100644 --- a/src/main/orcad/orcad-exit-code.ts +++ b/src/main/orcad/orcad-exit-code.ts @@ -1,6 +1,6 @@ import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' -import { OrcadInstanceLockError } from './orcad-instance-lock' +import { OrcadInstanceLockError } from './orcad-data-root-privacy' import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' import { ORCAD_EXIT_CONFIGURATION } from './orcad-configuration-exit' diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index 4f75c7c9025d..dd581a02b56b 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -26,7 +26,7 @@ vi.mock('../daemon/daemon-health', () => ({ reportedCoverage.value.shift() ?? (process.platform === 'win32' ? 'handshake' : 'pty-spawn') }) })) -vi.mock('../daemon/daemon-init', () => ({ +vi.mock('../daemon/daemon-provider-state', () => ({ getDaemonEndpointFacts: getDaemonEndpointFactsMock, readDaemonPidRecord: readDaemonPidRecordMock, daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index d8cf2f705a28..4f891395f6eb 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -16,7 +16,7 @@ import { daemonOwnsFreshPersistentPtys, getDaemonEndpointFacts, readDaemonPidRecord -} from '../daemon/daemon-init' +} from '../daemon/daemon-provider-state' import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' import { ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' import type { OrcadIdleStopRecord } from '../../shared/orcad-idle-exit' diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts index 36dc3700f23b..0d97ccbdaf82 100644 --- a/src/main/orcad/orcad-instance-lock.test.ts +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -16,10 +16,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { acquireOrcadInstanceLock, ORCAD_LOCK_FILE_NAME, - OrcadInstanceLockError, type OrcadInstanceLockHooks, type OrcadLockRecord } from './orcad-instance-lock' +import { OrcadInstanceLockError } from './orcad-data-root-privacy' const roots: string[] = [] diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index 72fdb8263b73..ab152d2a2d83 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -35,8 +35,6 @@ const MAX_ORCAD_LOCK_BYTES = 64 * 1024 // A writer finishes in milliseconds; a garbled record older than this has no live writer. const GARBLED_LOCK_GRACE_MS = 10_000 -export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy' - const OrcadLockRecordSchema = z.object({ pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index 93937ef49b73..09241e5372e8 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -15,7 +15,7 @@ import { readAgentSessionRuntimeEnds } from '../runtime/agent-session-runtime-end-record' import { OrcadBindAddressError } from './orcad-bind-address' -import { OrcadInstanceLockError } from './orcad-instance-lock' +import { OrcadInstanceLockError } from './orcad-data-root-privacy' import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' @@ -42,7 +42,7 @@ describe('parseArgs', () => { expect(() => parseArgs(['--help', '--port', 'bad'])).toThrow('--port expects an integer') expect(() => parseArgs(['--help', '--bind'])).toThrow('--bind expects a value') expect(() => parseArgs(['--help', '--recipe-json'])).toThrow( - '--recipe-json requires --project-root' + 'Recipe JSON output requires --project-root.' ) for (const flag of ['--no-pairing', '--mobile-pairing']) { expect(() => parseArgs(['--help', '--grant-desktop-control', flag])).toThrow( @@ -62,24 +62,29 @@ describe('parseArgs', () => { }) it('takes the desktop serve flags orca serve forwards', () => { - expect( - parseArgs([ - '--mobile-pairing', - '--recipe-json', - '--project-root', - '/work/app', - '--no-pairing' - ]) - ).toEqual({ - mobilePairing: true, + expect(parseArgs(['--recipe-json', '--project-root', '/work/app'])).toEqual({ recipeJson: true, - projectRoot: '/work/app', - noPairing: true + projectRoot: '/work/app' }) - expect(() => parseArgs(['--recipe-json'])).toThrow('--recipe-json requires --project-root') + expect(parseArgs(['--mobile-pairing'])).toEqual({ mobilePairing: true }) + expect(parseArgs(['--no-pairing'])).toEqual({ noPairing: true }) + expect(() => parseArgs(['--recipe-json'])).toThrow( + 'Recipe JSON output requires --project-root.' + ) expect(() => parseArgs(['--project-root'])).toThrow('--project-root expects a value') }) + it('rejects contradictory pairing flags with the desktop serve message', () => { + expect(() => parseArgs(['--no-pairing', '--mobile-pairing'])).toThrow( + 'Use either --mobile-pairing or --no-pairing, not both.' + ) + for (const flag of ['--no-pairing', '--mobile-pairing']) { + expect(() => parseArgs(['--recipe-json', '--project-root', '/work/app', flag])).toThrow( + `Recipe JSON output requires runtime pairing; remove ${flag}.` + ) + } + }) + it('rejects --bind with no value rather than silently binding the default', () => { expect(() => parseArgs(['--bind'])).toThrow('--bind expects a value') expect(() => parseArgs(['--bind', '--json'])).not.toThrow() diff --git a/src/main/orcad/orcad-managed-idle-exit-host.ts b/src/main/orcad/orcad-managed-idle-exit-host.ts index 7ea30102d189..f7a6d455d3be 100644 --- a/src/main/orcad/orcad-managed-idle-exit-host.ts +++ b/src/main/orcad/orcad-managed-idle-exit-host.ts @@ -63,7 +63,7 @@ async function startOrcadManagedIdleExit( } ): Promise { const { getLocalPtyProvider } = await import('../ipc/pty') - const { getDaemonEndpointFacts } = await import('../daemon/daemon-init') + const { getDaemonEndpointFacts } = await import('../daemon/daemon-provider-state') const { countLiveOrcadDaemonSessions, retireOrcadDaemonIfIdle } = await import('./orcad-daemon-retirement') const idleRecord = createIdleStopRecordOwnership(input.userDataPath) diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index b2349b560b19..140def0eb6ad 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -79,7 +79,7 @@ vi.mock('./orcad-automations', () => ({ vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({ startAgentStateRulesLiveUpdates: () => {} })) -vi.mock('../daemon/daemon-init', () => ({ daemonOwnsFreshPersistentPtys: () => false })) +vi.mock('../daemon/daemon-provider-state', () => ({ daemonOwnsFreshPersistentPtys: () => false })) vi.mock('../ipc/pty', () => ({ registerHeadlessPtyRuntime: async () => {}, getLocalPtyProvider: () => null, diff --git a/src/main/startup/desktop-profile-instance-lock.test.ts b/src/main/startup/desktop-profile-instance-lock.test.ts index 8bf0ec121cf0..938ff74d174d 100644 --- a/src/main/startup/desktop-profile-instance-lock.test.ts +++ b/src/main/startup/desktop-profile-instance-lock.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { OrcadInstanceLockError, type OrcadInstanceLock } from '../orcad/orcad-instance-lock' +import type { OrcadInstanceLock } from '../orcad/orcad-instance-lock' +import { OrcadInstanceLockError } from '../orcad/orcad-data-root-privacy' import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock' describe('the desktop share of the profile instance lock', () => { diff --git a/src/main/startup/desktop-profile-instance-lock.ts b/src/main/startup/desktop-profile-instance-lock.ts index 076e5b627dc1..b16c5c4563a0 100644 --- a/src/main/startup/desktop-profile-instance-lock.ts +++ b/src/main/startup/desktop-profile-instance-lock.ts @@ -5,11 +5,8 @@ * Electron's single-instance lock only fences other Electron launches; orcad is a plain Node * process that cannot see it. Sharing orcad's lock file gives both hosts one owner record. */ -import { - acquireOrcadInstanceLock, - OrcadInstanceLockError, - type OrcadInstanceLock -} from '../orcad/orcad-instance-lock' +import { acquireOrcadInstanceLock, type OrcadInstanceLock } from '../orcad/orcad-instance-lock' +import { OrcadInstanceLockError } from '../orcad/orcad-data-root-privacy' import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics' export type DesktopProfileLockResult = diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 3e76bb855de6..20136d4e9d53 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -2,11 +2,8 @@ import { app } from 'electron' import { classifyError } from '../telemetry/classify-error' import { track } from '../telemetry/client' import { getPtyIdForPaneKey } from '../ipc/pty' -import { - getDaemonProvider, - initDaemonPtyProvider, - listLiveDaemonPtyIds -} from '../daemon/daemon-init' +import { getDaemonProvider, listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' +import { initDaemonPtyProvider } from '../daemon/daemon-provider-init' import { getCodexPaneAccount, hasAnyRecordedLegacyWslCodexPane, diff --git a/src/main/startup/main-process-quit-pending-writer.test.ts b/src/main/startup/main-process-quit-pending-writer.test.ts index cd2ddf840621..8bf383b8adf4 100644 --- a/src/main/startup/main-process-quit-pending-writer.test.ts +++ b/src/main/startup/main-process-quit-pending-writer.test.ts @@ -11,7 +11,7 @@ const dependencies: Record> = { '../ipc/worktree-base-directory-watcher': { disposeWorktreeBaseDirectoryWatchers: settled() }, '../ipc/folder-repo-git-upgrade': { stopFolderRepoGitUpgradeWatch: idle() }, '../ipc/pty': { killAllPty: idle() }, - '../daemon/daemon-init': { disconnectDaemon: settled(), shutdownDaemon: settled() }, + '../daemon/daemon-provider-state': { disconnectDaemon: settled(), shutdownDaemon: settled() }, '../ipc/ssh-shutdown-drain': { beginSshShutdown: settled() }, '../agent-hooks/server': { agentHookServer: { stop: idle() } }, '../agent-hooks/wsl-hook-relay-manager': { wslHookRelayManager: { disposeAll: idle() } }, diff --git a/src/main/startup/main-process-quit-update-veto.test.ts b/src/main/startup/main-process-quit-update-veto.test.ts index ea46417fe404..4164f2b039a0 100644 --- a/src/main/startup/main-process-quit-update-veto.test.ts +++ b/src/main/startup/main-process-quit-update-veto.test.ts @@ -6,7 +6,7 @@ const dependencyExports: [string, string[]][] = [ ['../ipc/worktree-base-directory-watcher', ['disposeWorktreeBaseDirectoryWatchers']], ['../ipc/folder-repo-git-upgrade', ['stopFolderRepoGitUpgradeWatch']], ['../ipc/pty', ['killAllPty']], - ['../daemon/daemon-init', ['disconnectDaemon', 'shutdownDaemon']], + ['../daemon/daemon-provider-state', ['disconnectDaemon', 'shutdownDaemon']], ['../ipc/ssh-shutdown-drain', ['beginSshShutdown']], ['../agent-hooks/server', ['agentHookServer']], ['../agent-hooks/wsl-hook-relay-manager', ['wslHookRelayManager']], diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index eb2f35bfa99d..7e7af4c773e4 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -3,7 +3,7 @@ import { closeAllWatchers } from '../ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' import { stopFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' import { killAllPty } from '../ipc/pty' -import { disconnectDaemon, shutdownDaemon } from '../daemon/daemon-init' +import { disconnectDaemon, shutdownDaemon } from '../daemon/daemon-provider-state' import { beginSshShutdown } from '../ipc/ssh-shutdown-drain' import { agentHookServer } from '../agent-hooks/server' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 61e36137688f..a003e5b42ccd 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -16,7 +16,7 @@ import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-sessio import { prepareCodexPinnedLaunchHome } from './codex-session-resume-launch' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { getDaemonProvider } from '../daemon/daemon-init' +import { getDaemonProvider } from '../daemon/daemon-provider-state' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { OrchestrationEnvironmentTransport } from '../runtime/orchestration/environment-transport' import { resolveEnvironment } from '../../shared/runtime-environment-store' diff --git a/src/shared/daemon-lifecycle-telemetry.ts b/src/shared/daemon-lifecycle-telemetry.ts index 88b6f8f287f3..afa9c71ec7c8 100644 --- a/src/shared/daemon-lifecycle-telemetry.ts +++ b/src/shared/daemon-lifecycle-telemetry.ts @@ -3,7 +3,7 @@ // subprocess and never crosses into the app, so every reason here is one the app // itself decides: a startup replace, or an observed death→respawn. -// Startup launcher replaced a still-connectable daemon (each maps 1:1 to a `daemon-init.ts` decision). +// Startup launcher replaced a still-connectable daemon (each maps 1:1 to a `daemon-replacement-preflight.ts` decision). export const DAEMON_REPLACE_REASONS = [ 'unhealthy_resolver', 'stale_bundle', diff --git a/tests/e2e/fixtures/daemon-generation-desktop-discovery.ts b/tests/e2e/fixtures/daemon-generation-desktop-discovery.ts index 92743a443dd5..e208ded947b5 100644 --- a/tests/e2e/fixtures/daemon-generation-desktop-discovery.ts +++ b/tests/e2e/fixtures/daemon-generation-desktop-discovery.ts @@ -1,5 +1,5 @@ import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter' -import { createLegacyDaemonAdapters } from '../../../src/main/daemon/daemon-init' +import { createLegacyDaemonAdapters } from '../../../src/main/daemon/daemon-legacy-adapters' import { DaemonPtyRouter } from '../../../src/main/daemon/daemon-pty-router' export type DesktopDiscoveryGeneration = { diff --git a/tests/tools/win-update-e2e/daemon-processes.mjs b/tests/tools/win-update-e2e/daemon-processes.mjs index d292c0f24b88..950068a6de47 100644 --- a/tests/tools/win-update-e2e/daemon-processes.mjs +++ b/tests/tools/win-update-e2e/daemon-processes.mjs @@ -5,7 +5,7 @@ // matched by executable name. The only reliable discriminators are the // command-line markers the fork always passes: the daemon entry script // (daemon-entry.js) and its --socket / --token arguments. See -// src/main/daemon/daemon-init.ts (createOutOfProcessLauncher). +// src/main/daemon/daemon-out-of-process-launcher.ts (createOutOfProcessLauncher). // // The daemon also writes a PID file at /daemon/daemon-v.pid whose // JSON carries { pid, startedAtMs, entryPath, appVersion } — the appVersion From 6d516517115fedafe1e1e62b51300614e3e0631a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:59:07 -0700 Subject: [PATCH 55/58] [CI] Remove copied probes and advance queued-card test waits (#27447) --- src/main/claude/statusline-script.test.ts | 25 ------------- ...ed-agent-session-queued-card-holds.test.ts | 11 ++++-- src/main/ripgrep/fd-exhausted-spawn.test.ts | 36 ------------------- 3 files changed, 9 insertions(+), 63 deletions(-) delete mode 100644 src/main/ripgrep/fd-exhausted-spawn.test.ts diff --git a/src/main/claude/statusline-script.test.ts b/src/main/claude/statusline-script.test.ts index 1f5f3f6d51b6..db2ea2f1f837 100644 --- a/src/main/claude/statusline-script.test.ts +++ b/src/main/claude/statusline-script.test.ts @@ -134,31 +134,6 @@ describe('getManagedStatusLineScript (win32 local)', () => { }) }) -describe('statusline curl throttle (posix)', () => { - it('checks the per-pane stamp after the env guards and before curl', () => { - stubPlatform('darwin') - const script = getManagedStatusLineScript('local') - const envGuardIndex = script.indexOf('-z "$ORCA_AGENT_HOOK_PORT"') - const durationIndex = script.indexOf('"total_duration_ms"') - const stampIndex = script.indexOf('orca-claude-statusline-last-${orca_statusline_pane_id}') - const intervalIndex = script.indexOf(`-lt ${CLAUDE_STATUSLINE_MIN_POST_INTERVAL_SECONDS}`) - const curlIndex = script.indexOf('curl -sS') - expect(envGuardIndex).toBeLessThan(stampIndex) - expect(stampIndex).toBeLessThan(durationIndex) - expect(durationIndex).toBeLessThan(intervalIndex) - expect(intervalIndex).toBeLessThan(curlIndex) - // Fail-open shape: non-numeric date output or stamp content must never suppress the post. - // Why: the allow-list (not a mere digits check) matters — leading-zero values like 008 are - // invalid octal inside $(( )) and abort the whole script under dash, wedging the stamp. - expect(script).toContain( - 'case "$orca_statusline_now" in 0|[1-9]|[1-9][0-9]*) ;; *) orca_statusline_now=' - ) - expect(script).toContain( - 'case "$orca_statusline_last" in 0|[1-9]|[1-9][0-9]*) ;; *) orca_statusline_last=' - ) - }) -}) - describe.skipIf(process.platform === 'win32')('statusline curl throttle (posix behavioral)', () => { const LEAF_ID = '00000000-0000-4000-8000-000000000000' const PANE_KEY = 'tab-1:00000000-0000-4000-8000-000000000000' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index b6e2fd41a018..915e781401ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -48,10 +48,17 @@ let rig: QueuedMessageTestRig const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } beforeEach(async () => { + vi.useFakeTimers() rig = await createQueuedMessageTestRig({ restartable: true }) }) -afterEach(() => rig.dispose()) +afterEach(async () => { + try { + await rig.dispose() + } finally { + vi.useRealTimers() + } +}) async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result @@ -87,7 +94,7 @@ function derivedPauses(): string[] { /** Lets the drain run its steps; none may hand a card off. */ async function expectNothingSent(...draftIds: string[]): Promise { - await new Promise((resolve) => setTimeout(resolve, 250)) + await vi.advanceTimersByTimeAsync(250) for (const draftId of draftIds) { expect(await rig.handoff(draftId)).toBeUndefined() } diff --git a/src/main/ripgrep/fd-exhausted-spawn.test.ts b/src/main/ripgrep/fd-exhausted-spawn.test.ts deleted file mode 100644 index 7509dc5f1893..000000000000 --- a/src/main/ripgrep/fd-exhausted-spawn.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { describe, expect, it } from 'vitest' - -// Exhaust descriptors in a bounded subprocess so the test runner keeps its own handles. -describe.runIf(process.platform !== 'win32')('descriptor-exhausted ripgrep spawn', () => { - it('can attach the error listener when a failed spawn has no stdout', () => { - const script = ` - const { spawn } = require('node:child_process'); - const { openSync, closeSync } = require('node:fs'); - const descriptors = []; - try { while (true) descriptors.push(openSync('/dev/null', 'r')); } catch {} - let result; - try { - const child = spawn(process.execPath, ['-e', ''], { stdio: ['ignore', 'pipe', 'pipe'] }); - result = { hasStdout: Boolean(child.stdout), hasPid: Boolean(child.pid) }; - child.stdout?.setEncoding('utf8'); - child.stderr?.resume(); - child.once('error', (error) => console.log(JSON.stringify({ ...result, code: error.code }))); - } catch (error) { - result = { code: error.code }; - process.nextTick(() => console.log(JSON.stringify(result))); - } finally { - for (const descriptor of descriptors) closeSync(descriptor); - } - ` - const output = execFileSync( - '/bin/sh', - ['-c', 'ulimit -n 128; exec "$@"', 'sh', process.execPath, '-e', script], - { - encoding: 'utf8', - timeout: 10_000 - } - ) - expect(JSON.parse(output)).toMatchObject({ code: 'EMFILE' }) - }) -}) From 4468a5375f282434f603c3a2044a238486179d10 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 04:17:28 -0700 Subject: [PATCH 56/58] fix(terminal): phone wake reaches mounted hibernated agent panes (#27450) The pane listener subscribed to 'orca:wake-hibernated-agents-worktree' while the dispatcher fires WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT ('orca-wake-...'), so the in-place wake never ran. Extract the listener and bind it to the shared constant; the dispatcher test now drives the real listener instead of a stand-in. Co-authored-by: m4air --- .../hibernated-agent-wake-listener.ts | 33 +++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 30 ++++------------- ...wake-sleeping-agents-in-background.test.ts | 24 ++++++++------ 3 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/hibernated-agent-wake-listener.ts diff --git a/src/renderer/src/components/terminal-pane/hibernated-agent-wake-listener.ts b/src/renderer/src/components/terminal-pane/hibernated-agent-wake-listener.ts new file mode 100644 index 000000000000..4d162552825c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hibernated-agent-wake-listener.ts @@ -0,0 +1,33 @@ +import type { IDisposable } from '@xterm/xterm' +import { + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type WakeHibernatedAgentsWorktreeDetail +} from '@/constants/terminal' + +type WakeablePaneBinding = IDisposable & { + wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null +} + +/** Wakes this pane's armed hibernated agents when the worktree-wide wake event targets it. */ +export function listenForHibernatedAgentWake( + worktreeId: string, + getBindings: () => Iterable +): () => void { + const onWake = (event: Event): void => { + if (!(event instanceof CustomEvent)) { + return + } + const detail: WakeHibernatedAgentsWorktreeDetail | null = event.detail + if (!detail || detail.worktreeId !== worktreeId) { + return + } + for (const binding of getBindings()) { + const claimKey = binding.wakeHibernatedAgentIfArmed?.(detail.wokenClaimKeys) + if (claimKey) { + detail.wokenClaimKeys?.add(claimKey) + } + } + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + return () => window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) +} 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 944a2e6bbe0c..e8eae7e7e473 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 @@ -15,6 +15,7 @@ import { import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types' import { useTerminalPaneMountLifecycle } from './use-terminal-pane-mount-lifecycle' import { useTerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-refs' +import { listenForHibernatedAgentWake } from './hibernated-agent-wake-listener' export { applyTerminalScrollbackRowsToMountedPanes, @@ -51,26 +52,11 @@ export function useTerminalPaneLifecycle(deps: UseTerminalPaneLifecycleDeps): vo const systemPrefersDarkRef = refs.systemPrefersDarkRef systemPrefersDarkRef.current = deps.systemPrefersDark - useEffect(() => { - const onWakeHibernatedAgents = (event: Event): void => { - const detail = (event as CustomEvent<{ worktreeId: string; wokenClaimKeys?: Set }>) - .detail - if (!detail || detail.worktreeId !== deps.worktreeId) { - return - } - for (const panePtyBinding of deps.panePtyBindingsRef.current.values()) { - const claimKey = (panePtyBinding as IDisposableWithWake).wakeHibernatedAgentIfArmed?.( - detail.wokenClaimKeys - ) - if (claimKey) { - detail.wokenClaimKeys?.add(claimKey) - } - } - } - window.addEventListener('orca:wake-hibernated-agents-worktree', onWakeHibernatedAgents) - return () => - window.removeEventListener('orca:wake-hibernated-agents-worktree', onWakeHibernatedAgents) - }, [deps.worktreeId, deps.panePtyBindingsRef]) + useEffect( + () => + listenForHibernatedAgentWake(deps.worktreeId, () => deps.panePtyBindingsRef.current.values()), + [deps.worktreeId, deps.panePtyBindingsRef] + ) useEffect(() => { const previousIsVisible = getPreviousVisibleForTerminalPane({ @@ -193,10 +179,6 @@ export function useTerminalPaneLifecycle(deps: UseTerminalPaneLifecycleDeps): vo }, [deps.settings?.terminalMouseHideWhileTyping, deps.managerRef, refs.mouseHideDisposablesRef]) } -type IDisposableWithWake = IDisposable & { - wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null -} - type IDisposableWithVisibility = IDisposable & { syncProcessTracking?: () => void noteVisibilityResume?: () => void diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts index dc8dc8452b8f..3bd227e52932 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -7,6 +7,7 @@ import { type BackgroundMountTerminalWorktreeDetail, type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' +import { listenForHibernatedAgentWake } from '@/components/terminal-pane/hibernated-agent-wake-listener' import type { ResumeSleepingAgentSessionsOptions } from './resume-sleeping-agent-session' const resumeSpy = vi.fn< @@ -379,21 +380,24 @@ describe('wakeSleepingAgentsForWorktreeInBackground', () => { ) }) - it('passes claims consumed by mounted panes to the generic resume as skipClaimKeys', () => { + it('wakes mounted panes through the real pane listener and skips their claims', () => { sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } isPassiveSpy.mockReturnValue(false) - // A mounted pane consuming the in-place wake adds its claim key to the - // event detail — exactly what use-terminal-pane-lifecycle does. - const onWake = (event: Event): void => { - ;(event as CustomEvent).detail.wokenClaimKeys?.add( - 'claim-1' - ) - } - window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + const wakeHibernatedAgentIfArmed = vi.fn(() => 'claim-1') + const otherWorktreeWake = vi.fn(() => 'claim-other') + const stopListening = listenForHibernatedAgentWake('wt-1', () => [ + { dispose: () => {}, wakeHibernatedAgentIfArmed } + ]) + const stopOtherListening = listenForHibernatedAgentWake('wt-2', () => [ + { dispose: () => {}, wakeHibernatedAgentIfArmed: otherWorktreeWake } + ]) wakeSleepingAgentsForWorktreeInBackground('wt-1') - window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + stopListening() + stopOtherListening() + expect(wakeHibernatedAgentIfArmed).toHaveBeenCalledTimes(1) + expect(otherWorktreeWake).not.toHaveBeenCalled() const options = resumeSpy.mock.calls[0]?.[1] expect(options?.skipClaimKeys?.has('claim-1')).toBe(true) }) From 58ffb7e9634b8f75e82b0d06ec33cf80de6d21ac Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:51:07 +0800 Subject: [PATCH 57/58] fix(ci): classify pet.speak.subscribe for mobile desktop relay Upstream's new relayed-stream-carrier census requires every execution-host streaming method to name how replies carry a stream id. Fork pet.speak.subscribe is now routed as execution-host (same as nativeChat), so the census failed on PR Checks shard 5/5. JSON speak events have no host-allocated stream id. --- docs/audits/sync-notes-2026-10-11c.md | 4 +++- .../runtime/mobile-desktop-relay/relayed-stream-carriers.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/audits/sync-notes-2026-10-11c.md b/docs/audits/sync-notes-2026-10-11c.md index dcfb155d0659..daf128b7883a 100644 --- a/docs/audits/sync-notes-2026-10-11c.md +++ b/docs/audits/sync-notes-2026-10-11c.md @@ -57,7 +57,9 @@ the fork pet-speech device-registry cleanup already auto-merged after it. Took upstream: allowlist is now `new Set(MOBILE_RPC_METHOD_ROUTES.keys())`. Added the five fork `pet.speak.*` methods to `EXECUTION_HOST_METHODS` in `runtime-rpc-mobile-method-routing.ts` (`pet: 'host'`), so the derived -allowlist still authorizes the phone pet-speech RPCs. +allowlist still authorizes the phone pet-speech RPCs. Classified +`pet.speak.subscribe` as a `'none'` relayed stream carrier (JSON events, +no host-allocated stream id), next to `nativeChat.subscribe`. ### `src/renderer/src/components/automations/use-automations-page-store-state.ts` diff --git a/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts b/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts index da3ef79f5eea..7632016576e5 100644 --- a/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts +++ b/src/main/runtime/mobile-desktop-relay/relayed-stream-carriers.ts @@ -14,6 +14,7 @@ export const RELAYED_STREAM_CARRIERS: ReadonlyMap ['agentSession.subscribe', 'none'], ['agentSession.subscribeStatus', 'none'], ['nativeChat.subscribe', 'none'], + ['pet.speak.subscribe', 'none'], ['session.tabs.subscribe', 'none'], ['session.tabs.subscribeAll', 'none'] ]) From 0de56821f72bdb71fcd1f8dabaccc21d1620a92d Mon Sep 17 00:00:00 2001 From: karl <142769090+karlorz@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:08:51 +0800 Subject: [PATCH 58/58] fix(ci): expose fs.constants on daemon-init test mocks Bun aliases `fs` to `node:fs`. Upstream doc-preview-file-access reads O_NOFOLLOW at import time, so the daemon-init fs stub without `constants` failed shard 5/5 after the pet.speak carrier fix. --- docs/audits/sync-notes-2026-10-11c.md | 9 +++++++++ src/main/daemon/daemon-init-dependency-mocks.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/docs/audits/sync-notes-2026-10-11c.md b/docs/audits/sync-notes-2026-10-11c.md index daf128b7883a..67ed502bc9f1 100644 --- a/docs/audits/sync-notes-2026-10-11c.md +++ b/docs/audits/sync-notes-2026-10-11c.md @@ -78,6 +78,15 @@ Combined: - DeepSeek picker / `agentFlags` launch tests and relay-install retry tests were not in the conflict set; auto-merge kept them. +## CI follow-up + +- Shard 5/5: `relayed-stream-carriers` census required `pet.speak.subscribe` + (`none` carrier). Fixed in `58ffb7e9634`. +- Shard 5/5 retry: `daemon-init-child-startup-failure` hit bun's `fs`/`node:fs` + alias without `constants` (upstream doc-preview reads `O_NOFOLLOW` at import). + Add `constants` to the daemon-init `fs` mock. +- Shard 1/5: cmd-j recent-tabs order failed once; 31/31 locally. Flake. + ## Light local checks (this box, via `heavy-run`, glibc Node 22) - `pnpm install --ignore-scripts` then `pnpm --filter @orca/process-host build`. diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index d7217d4df635..6d541aad52c4 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -163,6 +163,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { return { fs: () => ({ + // Why: bun aliases `fs` to `node:fs`; doc-preview-file-access reads constants at import. + constants: { O_NOFOLLOW: 0, O_NONBLOCK: 0 }, mkdirSync: vi.fn<(...args: unknown[]) => void>(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), unlinkSync: unlinkSyncMock,