Merge commit '9d305f3ce4f' into brennanb2025/conv-c1-host-planner

This commit is contained in:
Brennan Benson
2026-10-11 19:41:33 -07:00
745 changed files with 10887 additions and 30552 deletions
+17 -11
View File
@@ -27,6 +27,12 @@ on:
required: false
type: boolean
default: true
# Why: artifact names are unique per workflow run, and pr.yml calls this workflow twice.
artifact_suffix:
description: Appended to the build and trace artifact names so two calls in one run do not collide
required: false
type: string
default: ''
workflow_dispatch:
inputs:
ref:
@@ -91,7 +97,7 @@ jobs:
- name: Upload E2E build output
uses: actions/upload-artifact@v7
with:
name: e2e-build-out
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
# Why: build-relay.mjs writes each relay's marker as `out/relay/<platform>/.version`,
# and upload-artifact drops dotfiles by default — consumers then fail SSH specs with
@@ -201,7 +207,7 @@ jobs:
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
@@ -291,7 +297,7 @@ jobs:
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
- name: Run changed E2E specs
@@ -345,7 +351,7 @@ jobs:
if: failure() || cancelled()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-changed
name: playwright-traces-changed${{ inputs.artifact_suffix }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -393,7 +399,7 @@ jobs:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
# The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node.
# The template is what `orca serve`'s default selection materializes that slot from.
@@ -423,7 +429,7 @@ jobs:
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-serve-mode-switch-traces
name: orcad-serve-mode-switch-traces${{ inputs.artifact_suffix }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -451,7 +457,7 @@ jobs:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
# Built to a path the app does not look at by default, so the relay phase has no template.
- name: Build the linux-x64-glibc orcad template
@@ -474,7 +480,7 @@ jobs:
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-auto-convert-docker-traces
name: orcad-auto-convert-docker-traces${{ inputs.artifact_suffix }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -501,7 +507,7 @@ jobs:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
name: e2e-build-out${{ inputs.artifact_suffix }}
path: out/
- name: Build the linux-x64-glibc orcad template
run: |
@@ -522,7 +528,7 @@ jobs:
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-journeys-docker-traces
name: orcad-journeys-docker-traces${{ inputs.artifact_suffix }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -580,7 +586,7 @@ jobs:
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-serve-mode-switch-windows-traces
name: orcad-serve-mode-switch-windows-traces${{ inputs.artifact_suffix }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
-2
View File
@@ -104,8 +104,6 @@ jobs:
- name: Expo prebuild
env:
ORCA_IOS_APS_ENVIRONMENT: production
# Set the repo variable to 1 only after the App ID has Associated Domains enabled.
ORCA_IOS_ASSOCIATED_DOMAINS: ${{ vars.ORCA_IOS_ASSOCIATED_DOMAINS }}
run: npx expo prebuild --platform ios --no-install
- name: Install CocoaPods
+1
View File
@@ -36,6 +36,7 @@ on:
- 'config/scripts/mobile-release-check-scope*'
- 'config/scripts/mobile-test-change-scope*'
- 'config/scripts/pr-code-change-scope.mjs'
- 'config/scripts/pr-layout-oracle-route.mjs'
# Why main too: two pull requests can each pass against their own base and disagree once both
# land, and `verify` never runs on main. The same source paths as above, less the CI inputs only
# `verify` reads.
+6
View File
@@ -205,6 +205,12 @@ jobs:
env:
ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }}
BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }}
- name: Build the host-target desktop server template
if: runner.os == 'Linux'
run: node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc
- name: Verify a structured chat from the desktop server template
if: runner.os == 'Linux'
run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2
# 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
+36 -3
View File
@@ -50,6 +50,7 @@ jobs:
managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }}
package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }}
package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }}
layout_oracle: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }}
e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }}
test_files: ${{ steps.e2e_filter.outputs.test_files }}
e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }}
@@ -75,6 +76,7 @@ jobs:
/.github/scripts/check-root-directory-entries.mjs
/config/scripts/check-readme-local-links.mjs
/config/scripts/pr-code-change-scope.mjs
/config/scripts/pr-layout-oracle-route.mjs
/config/scripts/pr-e2e-source-routing.mjs
/config/scripts/ci-e2e-job-selection.mjs
/config/scripts/pr-ready-check-reuse.mjs
@@ -118,7 +120,7 @@ jobs:
printf '%s\n' "$CHANGED"
MOBILE_CI_ARGS=()
if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then
MOBILE_CI_ARGS=(--mobile-ci-tools)
MOBILE_CI_ARGS=(--mobile-ci-tools --daemon-init-test-support)
fi
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT"
@@ -131,9 +133,13 @@ jobs:
BASE="${{ github.event.pull_request.base.sha }}"
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")"
CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)"
E2E_SCOPE_ARGS=()
if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then
E2E_SCOPE_ARGS=(--daemon-init-test-support)
fi
# Source routes are executable contracts so a test can prove exact
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs "${E2E_SCOPE_ARGS[@]}")"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"
# Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must
@@ -144,7 +150,7 @@ jobs:
WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)"
echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow)"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow "${E2E_SCOPE_ARGS[@]}")"
if [ "$SHOULD_RUN" = true ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Changed E2E specs: $TEST_FILES_JSON"
@@ -1373,6 +1379,29 @@ jobs:
run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }}
needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }}
# Why its own required job, apart from `e2e`: the rest of the suite is red on main, but the
# layout oracle is green there, and its known-on-main list absorbs today's findings. Only the two
# oracle specs run, through the same reusable workflow: the window spec in the changed-spec lane,
# the headless spec in the orcad lane on both serve hosts.
layout_oracle:
name: layout oracle
needs: [code_paths, preflight]
if: >-
!cancelled() &&
needs.code_paths.result == 'success' &&
needs.code_paths.outputs.layout_oracle == 'true' &&
needs.preflight.result == 'success'
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
with:
# Why the merge commit, unlike `e2e`: a gate must test what lands on main, not a stale head.
ref: ${{ github.sha }}
test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts"]'
run_changed_e2e: true
needs_build: true
artifact_suffix: -layout-oracle
# Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability
# is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It
# reports a red X on the PR without blocking, exactly like `e2e` above. Deliberately no
@@ -1413,6 +1442,7 @@ jobs:
- managed_hook_node18
- package
- package_windows
- layout_oracle
# Evaluating job results only needs the lightweight container runner.
runs-on: ubuntu-slim
timeout-minutes: 5
@@ -1452,6 +1482,8 @@ jobs:
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
LAYOUT_ORACLE: ${{ needs.layout_oracle.result }}
LAYOUT_ORACLE_SHOULD_RUN: ${{ needs.code_paths.outputs.layout_oracle }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
@@ -1486,4 +1518,5 @@ jobs:
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
check_job layout_oracle "$LAYOUT_ORACLE" "$LAYOUT_ORACLE_SHOULD_RUN"
exit "$failed"
@@ -21,6 +21,9 @@ relay_base_url = "https://relay.onorca.dev"
# concurrency x instances. Scaling to 2 instances took placement failures 35% -> 70%.
relay_min_instances = 5
relay_max_instances = 5
# Proven on staging (2026-10-11, image F) before production turns them on.
relay_director_cloud_sql_proxy_sidecar = true
relay_event_loop_stall_alert = true
# Production cells run only on fixed-one GCE MIGs; Cloud Run remains the director.
relay_cells = {}
manage_relay_domain_mapping = true
+3 -6
View File
@@ -277,12 +277,9 @@
2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts
1 src/renderer/src/components/use-worktree-jump-palette-task-url.ts
1 src/renderer/src/components/worktree-jump-palette-create-worktree.ts
1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts
1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts
1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts
2 src/renderer/src/hooks/composer-state/composer-source-state.ts
1 src/renderer/src/hooks/composer-state/composer-target-state.ts
1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts
1 src/renderer/src/hooks/composer-state/composer-source-state.ts
1 src/renderer/src/hooks/composer-state/runtime-target-selection.ts
1 src/renderer/src/hooks/editor-external-watch-target-rows.ts
3 src/renderer/src/hooks/editor-external-watch-targets.ts
1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts
@@ -345,7 +342,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
3 src/renderer/src/lib/repo-runtime-owner.ts
2 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
+19 -88
View File
@@ -4263,28 +4263,25 @@
"terminal input availability",
"worktree discovery",
"Source Control status refresh",
"direct SSH detected-worktree scheduling",
"direct SSH reconnect telemetry",
"direct SSH host catalog authority",
"direct SSH generation-scope rollover"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. A macOS Electron client also completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.",
"coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7086",
"https://github.com/stablyai/orca/issues/16477"
],
"invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.",
"oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.",
"invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.",
"oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, and fill a partial pushed authority before clearing that target's PTY bindings once.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts",
"ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-external-worktree-discovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot"
"pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot"
],
"testFiles": [
"src/main/ipc/worktree-base-directory-event-filter.test.ts",
@@ -4299,13 +4296,8 @@
"src/main/window/attach-main-window-services.test.ts",
"tests/e2e/paired-external-worktree-discovery.spec.ts",
"src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts",
"src/main/ipc/repos-remote.test.ts",
"src/main/ssh/ssh-connection-generation.test.ts",
"src/main/ssh/ssh-provider-authority.test.ts",
"src/shared/direct-ssh-reconnect-telemetry-schema.test.ts",
"src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts",
"src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts",
"src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts"
"src/main/ssh/ssh-provider-authority.test.ts"
],
"assertionRefs": [
{
@@ -4367,7 +4359,7 @@
{
"file": "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts",
"assertions": [
"direct SSH coordinator telemetry is wired through the fail-soft product adapter"
"a filled partial SSH authority clears the target's PTY bindings once and a conflicting reply is rejected"
]
},
{
@@ -4403,13 +4395,6 @@
"the paired client's main-process shared-control transport keeps one connection epoch while converging and compares Windows paths case-insensitively"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
"a host-qualified catalog rejects contradictory executionHostId and connectionId provenance without returning rows",
"local, sibling SSH, and runtime rows remain excluded from the exact direct SSH catalog"
]
},
{
"file": "src/main/ssh/ssh-connection-generation.test.ts",
"assertions": [
@@ -4423,36 +4408,6 @@
"generation-scope rollover invalidates every target authority before abort callbacks run",
"every registered old-scope provider request aborts exactly once"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts",
"assertions": [
"coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin",
"the first timeout remains retrying and reports queue wait separately from provider execution",
"cancel debt admits at most two replacements and terminally distinguishes budget exhaustion"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts",
"assertions": [
"lineage and token creation remain blocked until a timed-out repo retry settles",
"exact overlapping preparation emits one aggregate with a join count",
"telemetry callback failure cannot affect reconnect completion"
]
},
{
"file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts",
"assertions": [
"timeout, rejection, cancellation, and stale outcomes have independent fields",
"target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected"
]
},
{
"file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts",
"assertions": [
"one coordinator aggregate maps to one typed product event with queue and provider percentiles",
"adapter failure is swallowed before it can reach recovery"
]
}
],
"evidenceRuns": [
@@ -4469,19 +4424,19 @@
"date": "2026-07-27",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.61,
"summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation."
"summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation. The third file, src/main/ipc/repos-remote.test.ts, was later deleted with the SSH repo IPC handlers."
},
{
"date": "2026-07-27",
"date": "2026-10-11",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.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 --reporter=dot",
"result": "passed",
"durationSeconds": 3.45,
"summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests."
"durationSeconds": 1.21,
"summary": "The direct SSH state-bridge wiring file passed with 6 tests after the reconnect coordinator, scheduler and telemetry were deleted."
},
{
"date": "2026-08-26",
@@ -4512,11 +4467,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion."
"evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. No intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed."
"evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows."
},
"promotionCriteria": [
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
@@ -12958,8 +12913,8 @@
"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-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-pending-spawn.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 vitest run --config config/vitest.config.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-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.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/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",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
@@ -13001,11 +12956,8 @@
"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/terminal-pane-tab-detach.test.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",
@@ -13172,13 +13124,6 @@
"a detached null-PTY leaf remains marked for pending activation before ownership transfer"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
"host-qualified repo catalogs require one consistent execution host and complete current SSH authority",
"contradictory, partial, mismatched, stale, and runtime-owned catalog requests fail closed"
]
},
{
"file": "src/main/ipc/ssh.test.ts",
"assertions": [
@@ -13270,20 +13215,6 @@
"a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts",
"assertions": [
"terminal invalidation and retry run synchronously before provider preparation",
"hydrated terminal finalization and same-authority correction are current-authority fenced",
"rapid authority rotation keeps immediate terminal checks while damping full preparation"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts",
"assertions": [
"exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts",
"assertions": [
@@ -13317,7 +13248,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-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-pending-spawn.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-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-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.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."
@@ -13353,7 +13284,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-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-pending-spawn.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-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-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.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."
+17
View File
@@ -9,6 +9,7 @@ import {
externalNativeAddons,
ORCAD_EXTERNAL_MODULES,
ORCAD_CHILD_ENTRY_POINTS,
ORCAD_COMMONJS_MODULE_OPTIONS,
orcadChildOutputFilename
} from './orcad-entry-build.mjs'
import { createRequire } from 'node:module'
@@ -52,6 +53,8 @@ import { computeOrcadFullVersion } from './orcad-artifact-version.mjs'
import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
import { runProcessSync } from '@orca/process-host'
import { describeProcessFailure } from './process-failure-message.mjs'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = process.env.ORCAD_OUT_DIR
@@ -99,6 +102,7 @@ async function stageParcelWatcher(target) {
const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js')
mkdirSync(WATCHER_MODULE_DIR, { recursive: true })
await build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
stdin: {
contents:
`const {createWrapper}=require(${JSON.stringify(wrapperSource)});` +
@@ -197,6 +201,7 @@ cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-cha
* their runtime resolvers look for them. */
function buildForkedChild(entryPoint, outfile) {
return build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
@@ -209,6 +214,7 @@ function buildForkedChild(entryPoint, outfile) {
minify: true,
sourcemap: false,
define: {
...ORCAD_COMMONJS_MODULE_OPTIONS.define,
'process.env.NODE_ENV': '"production"'
},
logLevel: 'error'
@@ -312,6 +318,17 @@ if (graphErrors.length > 0) {
)
process.exitCode = 1
}
const providerSmoke = runProcessSync({
program: process.execPath,
args: [SERVER_OUT_FILE, '--orcad-structured-provider-load-check'],
timeoutMs: 60_000
})
if (providerSmoke.code !== 0 || providerSmoke.timedOut || providerSmoke.outputTruncated) {
console.error(
`[build-orcad] the packaged structured provider failed to load.\n${describeProcessFailure(providerSmoke)}`
)
process.exitCode = 1
}
// Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
// top-level import, and calling its exported argv parser proves the entry's own code is
// there rather than a graph that merely resolved. Booting one would need a socket, a
+1 -13
View File
@@ -2672,10 +2672,8 @@
"src/main/ipc/repos-remote-base-ref-queries.test.ts": 1223,
"src/main/ipc/repos-remote-client-events.test.ts": 1788,
"src/main/ipc/repos-remote-git-username.test.ts": 1104,
"src/main/ipc/repos-remote.test.ts": 1533,
"src/main/ipc/repos-sparse-presets.test.ts": 1196,
"src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts": 1525,
"src/main/ipc/repos/remote-repo-registration.test.ts": 282,
"src/main/ipc/repos/repo-creation-git-availability.test.ts": 310,
"src/main/ipc/runtime-agent-launch-capability.test.ts": 3143,
"src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 1143,
@@ -2841,7 +2839,6 @@
"src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts": 559,
"src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 68,
"src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 197,
"src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 80,
"src/main/ipc/zcode-plan-credentials.test.ts": 58,
"src/main/issue-command-ignore.test.ts": 620,
"src/main/jcode/daemon-prewarm.test.ts": 54,
@@ -5118,7 +5115,6 @@
"src/main/runtime/windows-drive-listing.test.ts": 54,
"src/main/runtime/windows-firewall-remote-scope.test.ts": 81,
"src/main/runtime/windows-mobile-firewall.test.ts": 99,
"src/main/runtime/workspace-layout-round-trip-watch.test.ts": 254,
"src/main/runtime/workspace-layout-stream.test.ts": 257,
"src/main/runtime/workspace-session-membership-scaling.test.ts": 99,
"src/main/runtime/workspace-session-worktree-id.test.ts": 254,
@@ -7737,7 +7733,7 @@
"src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx": 3192,
"src/renderer/src/components/settings/sparse-preset-date.test.ts": 66,
"src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 53,
"src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts": 107,
"src/renderer/src/ssh/ssh-host-server-status-copy.test.ts": 107,
"src/renderer/src/components/settings/ssh-target-action-state.test.ts": 47,
"src/renderer/src/components/settings/ssh-target-draft.test.ts": 66,
"src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 114,
@@ -9019,14 +9015,8 @@
"src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1678,
"src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1661,
"src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 110,
"src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts": 1364,
"src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 79,
"src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 130,
"src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 49,
"src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 41,
"src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1605,
"src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 183,
"src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 72,
"src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts": 355,
"src/renderer/src/hooks/editor-external-watch-selected-host.test.ts": 324,
"src/renderer/src/hooks/editor-runtime-file-watch.test.ts": 55,
@@ -9298,7 +9288,6 @@
"src/renderer/src/lib/desktop-window-chrome.test.ts": 56,
"src/renderer/src/lib/diff-comment-compat.test.ts": 50,
"src/renderer/src/lib/diff-comments-format.test.ts": 47,
"src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 56,
"src/renderer/src/lib/direct-ssh-target-scope.test.ts": 70,
"src/renderer/src/lib/doc-preview-grants.test.ts": 370,
"src/renderer/src/lib/document-theme.test.ts": 55,
@@ -10835,7 +10824,6 @@
"src/shared/cross-platform-path.test.ts": 55,
"src/shared/currency-format.test.ts": 47,
"src/shared/daemon-adoption-telemetry.test.ts": 349,
"src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 279,
"src/shared/doc-preview-file-access.test.ts": 93,
"src/shared/doc-preview-scheme.test.ts": 44,
"src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 75,
@@ -4403,15 +4403,6 @@
"auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription": {
"ko": "이 호스트의 기존 Git 리포지토리 또는 폴더"
},
"auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription": {
"ko": "이 SSH 호스트의 기존 Git 리포지토리 또는 폴더"
},
"auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle": {
"ko": "SSH 호스트에서 프로젝트 열기"
},
"auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable": {
"ko": "아직 SSH 호스트에서는 사용할 수 없습니다."
},
"auto.components.sidebar.index.b826a98b6f": {
"ko": "사용 중"
},
@@ -137,6 +137,7 @@ function fixture(detectorStep = detector) {
for (const file of [
'package.json',
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/pr-layout-oracle-route.mjs',
'config/scripts/mobile-release-check-scope.mjs',
'config/scripts/mobile-test-change-scope.mjs'
]) {
@@ -9,6 +9,7 @@ const NON_TEST_FILES = new Set([
'.github/workflows/mobile-ios-release.yml',
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/pr-code-change-scope.test.mjs',
'config/scripts/pr-layout-oracle-route.mjs',
'config/scripts/mobile-release-check-scope.mjs',
'config/scripts/mobile-release-check-scope.test.mjs'
])
+5 -2
View File
@@ -52,6 +52,8 @@ const ALWAYS_PREFIXES = [
'src/main/persistence/',
'src/main/sqlite/',
'src/main/orcad/',
'src/main/runtime/',
'src/main/claude/',
'src/main/daemon/pty-subprocess/',
'src/main/providers/',
'config/patches/',
@@ -61,7 +63,7 @@ const ALWAYS_PREFIXES = [
]
export function discoverNodeServerTests(root = ROOT) {
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true })
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true })
return globSync(
['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'],
{ cwd: root }
@@ -150,11 +152,12 @@ export async function classifyNodeServerChanges(
if (changedFiles.length === 0) {
return { shouldRun: true, reason: 'No complete changed-file evidence' }
}
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true })
const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true })
const forced = changedFiles.find(
(file) =>
ALWAYS_FILES.has(file) ||
(ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) && !isSourceUnitTest(file)) ||
/^config\/scripts\/[^/]*orcad[^/]*$/.test(file) ||
selectors.some((selector) => file.includes(selector))
)
if (forced) {
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import { classifyNodeServerChanges } from './node-server-change-scope.mjs'
import { nodeServerTestPaths } from './node-server-test-paths.mjs'
it.each([
'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts',
'src/main/runtime/new-launch-path.ts',
'src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs',
'config/scripts/orcad-entry-build.mjs',
'config/scripts/future-orcad-package.mjs'
])('runs the packaged structured chat when its inputs change: %s', async (file) => {
expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true)
})
it('runs a real structured chat on Linux from the built desktop template', () => {
const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8'))
const steps = workflow.jobs.persistence.steps
const chat = steps.find((step) =>
step.run?.includes('orcad-packaged-structured-chat.integration.test.ts')
)
expect(chat.if).toBe("runner.os == 'Linux'")
expect(chat.run).toBe(
'pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2'
)
const template = steps.find((step) => step.run?.includes('build-orcad-template.mjs --targets'))
expect(template.if).toBe("runner.os == 'Linux'")
expect(template.run).toBe(
"node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc"
)
expect(steps.indexOf(template)).toBeGreaterThan(
steps.findIndex((step) => step.run === 'pnpm build:orcad')
)
expect(steps.indexOf(chat)).toBeGreaterThan(steps.indexOf(template))
expect(template['continue-on-error']).toBeUndefined()
expect(chat['continue-on-error']).toBeUndefined()
// Default --artifact runs have no template, so a required-input failure would follow.
expect(nodeServerTestPaths({ artifact: true, crossRuntime: true })).not.toContain(
'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts'
)
})
+12 -2
View File
@@ -7,7 +7,16 @@ export const CROSS_RUNTIME_TEST_PATHS = [
'src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts'
]
export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = {}) {
// Need the built desktop server template; CI runs them by path after build-orcad-template.mjs.
export const TEMPLATE_TEST_PATHS = [
'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts'
]
export function nodeServerTestPaths({
artifact = false,
crossRuntime = false,
template = false
} = {}) {
return [
'src/main/persistence/profile-state',
'src/main/persistence/loading-store/profile-state',
@@ -38,6 +47,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } =
'config/scripts/zip-extractor-command.test.mjs'
]
: []),
...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : [])
...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []),
...(template ? TEMPLATE_TEST_PATHS : [])
]
}
+19 -2
View File
@@ -25,6 +25,15 @@ export function orcadChildOutputFilename(entryPoint) {
export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
// Bundled ESM resolves from the deployed bundle, like CommonJS __filename.
export const ORCAD_COMMONJS_MODULE_OPTIONS = {
banner: {
js: 'var __orcadModuleUrl = require("node:url").pathToFileURL(__filename).href;'
},
define: { 'import.meta.url': '__orcadModuleUrl' },
logOverride: { 'empty-import-meta': 'error' }
}
// Native binaries are staged separately from every JavaScript entry.
export const externalNativeAddons = {
name: 'external-native-addons',
@@ -45,6 +54,7 @@ const jsoncParserEsm = {
export function buildOrcadEntry(outfile) {
return build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
entryPoints: [join(root, ORCAD_ENTRY_POINT)],
bundle: true,
platform: 'node',
@@ -56,7 +66,10 @@ export function buildOrcadEntry(outfile) {
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
define: {
...ORCAD_COMMONJS_MODULE_OPTIONS.define,
'process.env.NODE_ENV': '"production"'
},
logLevel: 'error'
})
}
@@ -81,6 +94,7 @@ export function buildOrcadLauncher(outfile) {
.update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME)))
.digest('hex')
return build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)],
bundle: true,
platform: 'node',
@@ -90,7 +104,10 @@ export function buildOrcadLauncher(outfile) {
metafile: true,
minify: true,
sourcemap: false,
define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) },
define: {
...ORCAD_COMMONJS_MODULE_OPTIONS.define,
ORCAD_SERVER_SHA256: JSON.stringify(serverSha256)
},
logLevel: 'error'
})
}
+79
View File
@@ -0,0 +1,79 @@
import { build } from 'esbuild'
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { buildOrcadEntry, ORCAD_COMMONJS_MODULE_OPTIONS } from './orcad-entry-build.mjs'
import { runProcessSync } from '@orca/process-host'
const directory = mkdtempSync(join(tmpdir(), 'orca-structured-provider-build-'))
const bundle = join(directory, 'orcad-server.js')
beforeAll(async () => {
await buildOrcadEntry(bundle)
}, 60_000)
afterAll(() => {
rmSync(directory, { recursive: true, force: true })
})
it('loads the structured Claude SDK through the packaged server under plain Node', () => {
const loaded = runProcessSync({
program: process.execPath,
args: [bundle, '--orcad-structured-provider-load-check'],
cwd: directory,
env: { ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 30_000
})
expect(loaded.code, loaded.stderr.slice(-2_000)).toBe(0)
}, 40_000)
it('resolves bundle-relative requires after deployment to a path with URL-special characters', async () => {
const buildDirectory = join(directory, 'build')
mkdirSync(buildDirectory)
const outfile = join(buildDirectory, 'entry.cjs')
await build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
stdin: {
contents: `import { createRequire } from 'node:module';
console.log(createRequire(import.meta.url)('./asset.cjs'));`,
resolveDir: directory
},
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile,
minify: true,
logLevel: 'error'
})
writeFileSync(join(buildDirectory, 'asset.cjs'), 'module.exports = "deployed asset"')
const deployedDirectory = join(directory, 'deployed # 100%')
renameSync(buildDirectory, deployedDirectory)
const loaded = runProcessSync({
program: process.execPath,
args: [join(deployedDirectory, 'entry.cjs')],
cwd: directory,
env: { ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 30_000
})
expect(loaded.code, loaded.stderr).toBe(0)
expect(loaded.stdout.trim()).toBe('deployed asset')
})
it.each(['import.meta', 'import.meta.dirname', 'import.meta.filename', 'import.meta.resolve'])(
'rejects unsupported %s instead of silently replacing it with an empty object',
async (expression) => {
await expect(
build({
...ORCAD_COMMONJS_MODULE_OPTIONS,
stdin: { contents: `console.log(${expression})`, resolveDir: directory },
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent'
})
).rejects.toThrow('"import.meta" is not available')
}
)
@@ -290,14 +290,21 @@ afterEach(() => {
}
})
function createMobileCiScopeRepository(withTool = true) {
function createMobileCiScopeRepository(
withTool = true,
toolPath = 'config/scripts/run-mobile-ci-tests.mjs',
companionPaths = []
) {
const directory = mkdtempSync(join(tmpdir(), 'orca-mobile-ci-scope-'))
mobileCiScopeDirectories.push(directory)
const root = resolve(import.meta.dirname, '../..')
for (const file of [
'package.json',
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/git-pull-request-diff-base.mjs'
'config/scripts/pr-layout-oracle-route.mjs',
'config/scripts/git-pull-request-diff-base.mjs',
'config/scripts/pr-e2e-source-routing.mjs',
'config/scripts/ci-e2e-job-selection.mjs'
]) {
mkdirSync(dirname(join(directory, file)), { recursive: true })
copyFileSync(join(root, file), join(directory, file))
@@ -323,17 +330,29 @@ function createMobileCiScopeRepository(withTool = true) {
git('init', '--quiet')
git('config', 'user.name', 'Scope control')
git('config', 'user.email', 'scope@example.invalid')
const tool = join(directory, 'config/scripts/run-mobile-ci-tests.mjs')
const tool = join(directory, toolPath)
mkdirSync(dirname(tool), { recursive: true })
if (withTool) {
writeFileSync(tool, 'export const revision = 1\n')
}
for (const file of companionPaths) {
mkdirSync(dirname(join(directory, file)), { recursive: true })
writeFileSync(join(directory, file), 'export const revision = 1\n')
}
const base = commit()
const detect = () => {
const output = join(directory, 'github-output')
const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === 'filter')
const detect = (stepId = 'filter') => {
const output = join(
directory,
stepId === 'filter' ? 'github-output' : `github-output-${stepId}`
)
const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === stepId)
const result = runProcessSync({
program: 'bash',
args: ['-e', '-c', step.run],
args: [
'-e',
'-c',
step.run.replace('${{ github.event.pull_request.base.sha }}', '$BASE_SHA')
],
cwd: directory,
env: {
...process.env,
@@ -397,3 +416,67 @@ describe.skipIf(process.platform === 'win32')('Mobile CI complete Git change evi
expect(result.stderr).toContain('not a git repository')
})
})
describe.skipIf(process.platform === 'win32')('Daemon mock complete Git change evidence', () => {
const helper = 'src/main/daemon/daemon-init-dependency-mocks.ts'
it('keeps real unit and type contracts while omitting unrelated packages for a modification', () => {
const repo = createMobileCiScopeRepository(true, helper)
writeFileSync(repo.tool, 'export const revision = 2\n')
repo.commit()
const { result, output } = repo.detect()
expect(result.code, result.stderr).toBe(0)
const flags = readFileSync(output, 'utf8')
expect(flags).toContain('package=false\n')
expect(flags).toContain('package_windows=false\n')
expect(flags).toContain('test=true\n')
expect(flags).toContain('typecheck=true\n')
})
it('routes the complete four-file modification through the real detector', () => {
const companions = [
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts',
'config/scripts/process-host-build-dist.test.mjs',
'src/main/codex/codex-app-server-connection.test.ts'
]
const repo = createMobileCiScopeRepository(true, helper, companions)
for (const file of [helper, ...companions]) {
writeFileSync(join(repo.directory, file), 'export const revision = 2\n')
}
repo.commit()
const { result, output } = repo.detect()
expect(result.code, result.stderr).toBe(0)
const flags = readFileSync(output, 'utf8')
for (const name of ['package', 'package_windows']) {
expect(classifyPrJobs([helper, ...companions])[name]).toBe(true)
expect(flags).toContain(`${name}=false\n`)
}
for (const name of ['test', 'static_analysis', 'typecheck', 'cross-version-wire']) {
expect(flags).toContain(`${name}=true\n`)
}
const e2e = repo.detect('e2e_filter')
expect(e2e.result.code, e2e.result.stderr).toBe(0)
const routes = readFileSync(e2e.output, 'utf8')
expect(routes).toContain('test_files=[]\n')
expect(routes).toContain('should_run=false\n')
})
it.each(['addition', 'deletion', 'rename', 'type change'])(
'keeps platform packages for an actual %s',
(change) => {
const repo = createMobileCiScopeRepository(change !== 'addition', helper)
if (change === 'addition') {
writeFileSync(repo.tool, 'export const revision = 1\n')
} else if (change === 'deletion') {
rmSync(repo.tool)
} else if (change === 'rename') {
repo.git('mv', helper, helper.replace('.ts', '-renamed.ts'))
} else {
rmSync(repo.tool)
symlinkSync('../../../config/scripts/pr-code-change-scope.mjs', repo.tool)
}
repo.commit()
const { result, output } = repo.detect()
expect(result.code, result.stderr).toBe(0)
expect(readFileSync(output, 'utf8')).toContain('package=true\n')
expect(readFileSync(output, 'utf8')).toContain('package_windows=true\n')
}
)
})
+38 -4
View File
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { isLayoutOracleSource } from './pr-layout-oracle-route.mjs'
const DOCS_ONLY_FILES = new Set([
'README.md',
@@ -30,7 +31,8 @@ export const PR_CHECK_JOBS = [
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
'package_windows',
'layout_oracle'
]
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
@@ -499,14 +501,43 @@ export function needsMobileDependencies(changedFiles) {
return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/'))
}
export function classifyPrJobs(changedFiles, { allowMobileCiTools = false } = {}) {
export const DAEMON_INIT_TEST_SUPPORT = 'src/main/daemon/daemon-init-dependency-mocks.ts'
const DAEMON_INIT_TEST_SCOPE = new Set([
DAEMON_INIT_TEST_SUPPORT,
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts',
'config/scripts/process-host-build-dist.test.mjs',
'src/main/codex/codex-app-server-connection.test.ts'
])
export function isDaemonInitTestSupportChange(files, modifiedOnly = false) {
return (
modifiedOnly &&
files.includes(DAEMON_INIT_TEST_SUPPORT) &&
new Set(files).size === files.length &&
files.every((file) => DAEMON_INIT_TEST_SCOPE.has(file))
)
}
export function classifyPrJobs(
changedFiles,
{ allowMobileCiTools = false, allowDaemonInitTestSupport = false } = {}
) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
const bundleFiles = isDaemonInitTestSupportChange(changedFiles, allowDaemonInitTestSupport)
? changedFiles.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT)
: changedFiles
const jobs = Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
shouldRun &&
(forceAll ||
ALWAYS_ON_CODE_JOBS.has(job) ||
jobDetector(job)(
['package', 'package_windows'].includes(job) ? bundleFiles : changedFiles
))
])
)
// Why outside should_run: a mobile-only diff is desktop-irrelevant and skips every job above,
@@ -562,6 +593,8 @@ function jobDetector(job) {
return (files) => files.some(isLinuxPackagePath)
case 'package_windows':
return (files) => files.some(isWindowsPackagePath)
case 'layout_oracle':
return (files) => files.some(isLayoutOracleSource)
default:
return () => true
}
@@ -634,7 +667,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
}
const files = input.split(/\r?\n/).filter(Boolean)
const classification = classifyPrJobs(files, {
allowMobileCiTools: process.argv.includes('--mobile-ci-tools')
allowMobileCiTools: process.argv.includes('--mobile-ci-tools'),
allowDaemonInitTestSupport: process.argv.includes('--daemon-init-test-support')
})
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+3 -1
View File
@@ -394,7 +394,9 @@ describe('per-job path classification', () => {
expectClassification([file], {
'cross-version-wire': true,
package: true,
package_windows: true
package_windows: true,
// The session-tabs RPC publishes layout, so the layout oracle gates it too.
layout_oracle: file === 'src/main/runtime/rpc/methods/session-tabs.ts'
})
}
expectClassification(
@@ -237,3 +237,25 @@ describe('PR E2E gate contract', () => {
)
})
})
describe('Daemon mock modified-only source routing', () => {
const helper = 'src/main/daemon/daemon-init-dependency-mocks.ts'
const verified = { allowDaemonInitTestSupport: true }
it('omits the real D7 route only for the proven unit-support scope', () => {
expect(selectPrE2eSpecs([helper])).toContain('tests/e2e/orcad-serve-mode-switch.spec.ts')
expect(selectPrE2eSpecs([helper], undefined, verified)).toEqual([])
const companion = 'src/main/codex/codex-app-server-connection.test.ts'
expect(selectPrE2eSpecs([helper, companion], undefined, verified)).toEqual(
selectPrE2eSpecs([companion])
)
})
it.each([
{ files: [] },
{ files: [helper, 'src/main/daemon/daemon-init.ts'] },
{ files: [helper, 'package.json'] },
{ files: [helper, 'src/main/daemon/unknown-support.ts'] },
{ files: [helper, helper] }
])('retains existing routes for an uncertain or mixed vector $files', ({ files }) => {
expect(selectPrE2eSpecs(files, undefined, verified)).toEqual(selectPrE2eSpecs(files))
})
})
@@ -1,71 +1,104 @@
import { describe, expect, it } from 'vitest'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { parse } from 'yaml'
import { classifyE2eJobs, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs'
import {
PR_E2E_SOURCE_ROUTES,
selectPrE2eSpecs,
shouldRunReusablePrE2e
} from './pr-e2e-source-routing.mjs'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
import { isLayoutOracleSource, LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs'
import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const ORACLE_ROUTE = PR_E2E_SOURCE_ROUTES.find((route) => route.id === 'workspace-layout.oracle')
const TAB_STORE_ROUTE = PR_E2E_SOURCE_ROUTES.find(
(route) => route.id === 'workspace-layout.oracle-tab-store'
)
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
/** One product file per layout writer family; each alone must run the oracle. */
const LAYOUT_AUTHORITIES = [
'src/shared/workspace-layout/workspace-layout-pane-commands.ts',
'src/shared/workspace-layout/old-client/index.ts',
'src/main/persistence/terminal-topology/terminal-topology-commit.ts',
'src/main/persistence/restoring-sessions/session-owner-fields.ts',
'src/main/persistence/loading-store/pty-binding-persistence.ts',
'src/main/remote-layout-snapshot/index.ts',
'src/main/runtime/orca-runtime-split-pty-backed-terminal.ts',
'src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts',
'src/main/runtime/orca-runtime-create-terminal.ts',
'src/renderer/src/runtime/mobile-session-tab-close.ts',
'src/renderer/src/runtime/runtime-layout-client.ts',
'src/renderer/src/store/slices/tabs.ts',
'src/renderer/src/store/slices/tabs/tabs-move-actions.ts',
'tests/e2e/workspace-layout-oracle-known-on-main.ts',
'tests/e2e/helpers/workspace-layout-oracle-compare.ts'
'tests/e2e/helpers/workspace-layout-oracle-compare.ts',
...LAYOUT_ORACLE_E2E_SPECS
]
describe('workspace layout oracle PR E2E routing', () => {
it('routes each layout authority on its own', () => {
expect(ORACLE_ROUTE, 'the oracle route was renamed or removed').toBeDefined()
for (const file of LAYOUT_AUTHORITIES) {
expect(selectPrE2eSpecs([file]), file).toEqual(ORACLE_ROUTE.specs.toSorted())
expect(shouldRunReusablePrE2e([file]), file).toBe(true)
}
})
/** Widened while the layout-holder stack is open: these still write layout. */
const STACK_WRITERS = [
'src/renderer/src/store/slices/terminals.ts',
'src/renderer/src/components/terminal-pane/TerminalPane.tsx',
'src/renderer/src/runtime/sync-runtime-graph.ts',
'src/renderer/src/runtime/web-session-tabs-sync.ts',
'src/main/runtime/orca-runtime-split-terminal.ts',
'src/main/runtime/orca-runtime-sync-window-graph.ts',
'src/main/runtime/mobile-session-layout-projection.ts',
'src/main/runtime/headless-runtime-graph.ts',
'src/main/persistence/loading-store/session-host-partitions.ts',
'src/main/persistence/loading-store/workspace-session-snapshot-publication.ts',
'src/renderer/src/store/slices/tab-group-reference-repair.ts',
'src/renderer/src/store/slices/terminal-session-row-hydration.ts',
'src/renderer/src/store/workspace-layout-cache.ts',
'src/renderer/src/app-shell/use-runtime-graph-sync.ts',
'src/renderer/src/components/tab-group/tab-drag-drop-commit.ts',
'src/shared/remote-layout-snapshot.ts',
'src/shared/terminal-leaf-move.ts',
'src/main/ipc/pty/ipc/leaf-move.ts',
'src/main/persistence/closed-terminal-tab-write-fence.ts',
'src/main/runtime/runtime-workspace-session-controller.ts',
'src/main/runtime/terminal-surface-close.ts',
'src/renderer/src/components/terminal/terminal-tab-actions.ts',
'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts',
'src/shared/rpc-contract/workspace-layout-params.ts',
'src/renderer/src/store/slices/recently-closed-tabs.ts'
]
it('runs the window and headless oracles, not the SSH lane, for the tab store', () => {
for (const file of [
'src/renderer/src/store/slices/tabs.ts',
'src/renderer/src/store/slices/tabs/tabs-move-actions.ts'
]) {
expect(selectPrE2eSpecs([file]), file).toEqual(TAB_STORE_ROUTE.specs.toSorted())
/** Every test file the oracle specs import, transitively: a change there can break the gate. */
function oracleHarnessFiles() {
const seen = new Set()
const pending = [...LAYOUT_ORACLE_E2E_SPECS]
while (pending.length > 0) {
const file = pending.pop()
if (seen.has(file)) {
continue
}
seen.add(file)
const source = readFileSync(join(projectDir, file), 'utf8')
for (const [, specifier] of source.matchAll(/(?:from|import)\s*\(?\s*'(\.[^']+)'/g)) {
const base = relative(projectDir, join(projectDir, dirname(file), specifier))
const resolved = [base, `${base}.ts`, `${base}.tsx`].find(
(candidate) => /\.tsx?$/.test(candidate) && existsSync(join(projectDir, candidate))
)
if (resolved?.startsWith('tests/')) {
pending.push(resolved)
}
}
}
return [...seen]
}
describe('workspace layout oracle merge gate', () => {
it('routes each layout authority and stack writer on its own', () => {
for (const file of [...LAYOUT_AUTHORITIES, ...STACK_WRITERS]) {
expect(isLayoutOracleSource(file), file).toBe(true)
expect(classifyPrJobs([file]).layout_oracle, file).toBe(true)
expect(existsSync(join(projectDir, file)), file).toBe(true)
}
})
it('sends each oracle spec to the job that can run it', () => {
const specs = ORACLE_ROUTE.specs
// Window spec runs in the general shards; headless runs in its dedicated job.
expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts'])
expect(classifyE2eJobs(JSON.stringify(specs))).toEqual({
e2e_run_changed: true,
e2e_needs_build: true
})
it('routes every test file the oracle specs import', () => {
const harness = oracleHarnessFiles()
expect(harness.length).toBeGreaterThan(LAYOUT_ORACLE_E2E_SPECS.length)
expect(harness.filter((file) => !isLayoutOracleSource(file))).toEqual([])
})
it('keeps every routed spec and authority a real file', () => {
for (const file of [...ORACLE_ROUTE.specs, ...LAYOUT_AUTHORITIES]) {
expect(existsSync(join(projectDir, file)), file).toBe(true)
}
})
it('leaves tests, fixtures and unrelated runtime modules alone', () => {
it('leaves tests, fixtures, harnesses and unrelated runtime modules alone', () => {
for (const file of [
'src/main/persistence/terminal-topology/terminal-topology-commit.test.ts',
'src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts',
@@ -75,12 +108,46 @@ describe('workspace layout oracle PR E2E routing', () => {
'src/main/runtime/rpc/methods/terminal/terminal-create.ts',
'src/main/runtime/runtime-rpc-database.ts',
'src/main/runtime/orca-runtime-files.ts',
'src/renderer/src/runtime/sync-runtime-graph.ts',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts',
'src/renderer/src/runtime/sync-runtime-graph-test-harness.ts',
'src/renderer/src/runtime/sync-runtime-graph.test.ts',
'tests/e2e/workspace-layout-oracle-latency.spec.ts'
]) {
expect(ORACLE_ROUTE.matches(file) || TAB_STORE_ROUTE.matches(file), file).toBe(false)
expect(isLayoutOracleSource(file), file).toBe(false)
}
expect(classifyPrJobs(['src/main/runtime/orca-runtime-files.ts']).layout_oracle).toBe(false)
})
it('keeps the oracle specs out of the advisory e2e job', () => {
expect(selectPrE2eSpecs(LAYOUT_AUTHORITIES)).toEqual([])
expect(shouldRunReusablePrE2e(LAYOUT_AUTHORITIES)).toBe(false)
})
it('runs exactly the oracle specs, each in the lane that can run it', () => {
const job = prWorkflow.jobs.layout_oracle
expect(job.uses).toBe('./.github/workflows/e2e.yml')
// The merge commit, so a stale-base PR cannot pass on an old main.
expect(job.with.ref).toBe('${{ github.sha }}')
const specs = JSON.parse(job.with.test_files)
expect(specs.toSorted()).toEqual(LAYOUT_ORACLE_E2E_SPECS.toSorted())
// Window spec runs in the changed-spec lane; headless runs in the orcad lane.
expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts'])
expect(classifyE2eJobs(job.with.test_files)).toEqual({
e2e_run_changed: true,
e2e_needs_build: true
})
})
it('blocks the merge on the oracle and passes when it is skipped', () => {
const verify = prWorkflow.jobs.verify
const gate = verify.steps.find((step) => step.name === 'Require successful checks')
expect(verify.needs).toContain('layout_oracle')
expect(gate.run).toContain(
'check_job layout_oracle "$LAYOUT_ORACLE" "$LAYOUT_ORACLE_SHOULD_RUN"'
)
// Two calls of the reusable workflow in one run must not share artifact names.
expect(prWorkflow.jobs.layout_oracle.with.artifact_suffix).toBeTruthy()
expect(prWorkflow.jobs.e2e.with.artifact_suffix).toBeUndefined()
})
})
+28 -35
View File
@@ -1,10 +1,13 @@
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { isUnitTestSupportSource } from './pr-code-change-scope.mjs'
import {
DAEMON_INIT_TEST_SUPPORT,
isDaemonInitTestSupportChange,
isUnitTestSupportSource
} from './pr-code-change-scope.mjs'
import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs'
const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file)
const isLayoutOracleSource = (file) =>
isProductSource(file) && !/\.spec\.ts$|-fixtures?\.ts$/.test(file)
// Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a
// patch edit can break composition without touching a file named "ime".
@@ -380,38 +383,25 @@ export const PR_E2E_SOURCE_ROUTES = [
/^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test(
file
)
},
{
// Why: the oracle is the only check that reads every view of the stored layout back. Runtime
// matches only the modules that create, move, close, persist or publish layout, not every
// session or terminal file, so the orcad build and Docker SSH lane stay off unrelated work.
id: 'workspace-layout.oracle',
specs: [
'tests/e2e/workspace-layout-oracle-headless.spec.ts',
'tests/e2e/workspace-layout-oracle.spec.ts'
],
matches: (file) =>
isLayoutOracleSource(file) &&
/^(?:src\/shared\/workspace-layout\/|src\/main\/persistence\/(?:terminal-topology\/|restoring-sessions\/|loading-store\/pty-binding-)|src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|create-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement)[^/]*\.ts$|tests\/e2e\/(?:helpers\/)?(?:workspace-layout-oracle|headless-layout-oracle|terminal-layout-journeys))/.test(
file
)
},
{
// The window's tab store reaches SSH through the same layout writers, so not the SSH lane.
id: 'workspace-layout.oracle-tab-store',
specs: [
'tests/e2e/workspace-layout-oracle-headless.spec.ts',
'tests/e2e/workspace-layout-oracle.spec.ts'
],
matches: (file) =>
isLayoutOracleSource(file) && file.startsWith('src/renderer/src/store/slices/tabs')
}
]
export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file)))
export function selectPrE2eSpecs(
changedPaths,
reportRoute = () => undefined,
{ allowDaemonInitTestSupport = false } = {}
) {
const routePaths = isDaemonInitTestSupportChange(changedPaths, allowDaemonInitTestSupport)
? changedPaths.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT)
: changedPaths
// The required layout_oracle job owns the oracle specs; running them here too would double the cost.
const specs = new Set(
changedPaths.filter(
(file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file) && !LAYOUT_ORACLE_E2E_SPECS.includes(file)
)
)
for (const route of PR_E2E_SOURCE_ROUTES) {
const matchedFiles = changedPaths.filter(route.matches)
const matchedFiles = routePaths.filter(route.matches)
if (matchedFiles.length === 0) {
continue
}
@@ -432,9 +422,9 @@ export function hasNativeImeSourceChange(changedPaths) {
).some((route) => changedPaths.some(route.matches))
}
export function shouldRunReusablePrE2e(changedPaths) {
export function shouldRunReusablePrE2e(changedPaths, options = {}) {
// Native IME has its own workflow.
return selectPrE2eSpecs(changedPaths).some(
return selectPrE2eSpecs(changedPaths, undefined, options).some(
(spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
)
}
@@ -453,14 +443,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
const options = {
allowDaemonInitTestSupport: process.argv.includes('--daemon-init-test-support')
}
if (process.argv.includes('--reusable-workflow')) {
process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`)
process.stdout.write(`${shouldRunReusablePrE2e(changedPaths, options)}\n`)
} else if (process.argv.includes('--wsl-source')) {
process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`)
} else if (process.argv.includes('--native-ime-source')) {
process.stdout.write(`${hasNativeImeSourceChange(changedPaths)}\n`)
} else {
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message), options)
process.stdout.write(`${JSON.stringify(specs)}\n`)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Paths whose change runs the required `layout_oracle` job in pr.yml.
/** The layout oracle's two specs; the job runs both and nothing else. */
export const LAYOUT_ORACLE_E2E_SPECS = [
'tests/e2e/workspace-layout-oracle-headless.spec.ts',
'tests/e2e/workspace-layout-oracle.spec.ts'
]
const LAYOUT_ORACLE_PREFIXES = [
// Layout model, persistence and compatibility folders.
'src/shared/workspace-layout/',
'src/shared/remote-layout-snapshot.ts',
'src/shared/terminal-leaf-move.ts',
'src/main/persistence/terminal-topology/',
'src/main/persistence/restoring-sessions/',
'src/main/persistence/loading-store/pty-binding-',
'src/main/persistence/loading-store/session-host-partitions.ts',
'src/main/persistence/loading-store/workspace-session-snapshot-publication.ts',
'src/main/remote-layout-snapshot/',
'src/main/runtime/old-session-tabs-stream/',
'src/renderer/src/runtime/remote-workspace-frozen/',
// Window-side layout writers the layout-holder stack replaces; widened while it is open.
'src/renderer/src/store/slices/tabs',
'src/renderer/src/store/slices/tab-group',
'src/renderer/src/store/slices/terminals',
'src/renderer/src/store/slices/terminal-',
'src/renderer/src/store/terminals/',
'src/renderer/src/store/workspace-layout-cache',
'src/renderer/src/components/terminal-pane/',
'src/renderer/src/components/tab-group/tab-drag-drop-commit.ts',
'src/renderer/src/app-shell/use-runtime-graph-sync.ts',
'src/renderer/src/runtime/sync-runtime-graph',
'src/renderer/src/runtime/web-session-tabs-sync',
// Other layout writers the stack edits.
'src/main/ipc/pty/ipc/leaf-move.ts',
'src/main/persistence/closed-terminal-tab-write-fence.ts',
'src/main/runtime/runtime-workspace-session-controller.ts',
'src/main/runtime/terminal-surface-close.ts',
'src/renderer/src/components/terminal/terminal-tab-actions.ts',
'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts',
'src/shared/rpc-contract/workspace-layout-params.ts',
'src/renderer/src/store/slices/recently-closed-tabs.ts',
// The client the oracle reads every view through.
'src/cli/runtime/client.ts',
'src/shared/remote-runtime-client.ts',
'src/shared/remote-runtime-subscription-transport.ts',
'src/shared/paired-runtime-connection.ts',
'src/shared/pairing.ts',
// The harness: the oracle imports most of the shared e2e helpers, not just its own.
'tests/e2e/workspace-layout-oracle',
'tests/e2e/terminal-probe-input-sequence.ts',
'tests/e2e/global-',
'tests/e2e/helpers/',
'tests/playwright.config.ts',
'.github/scripts/e2e-with-window-manager.sh',
// The job itself: the called workflow, its window-spec lane picker, and this route.
'.github/workflows/e2e.yml',
'config/scripts/ci-e2e-job-selection.mjs',
'config/scripts/pr-layout-oracle-route.mjs'
]
// Runtime matches only the modules that create, move, close, persist or publish layout.
const LAYOUT_ORACLE_RUNTIME_MODULE =
/^src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|layout-command-router|layout-projection|create-terminal|split-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement|sync-window-graph|runtime-graph|leaf-move)[^/]*\.ts$/
// Why fixtures only under src/: there they serve unit tests; e2e fixtures are the oracle's harness.
const TEST_SUPPORT =
/\.(?:test|spec)\.(?:[cm]?js|tsx?)$|\/__tests__\/|^src\/.*-fixtures?\.ts$|-test-harness\.tsx?$/
export function isLayoutOracleSource(file) {
if (LAYOUT_ORACLE_E2E_SPECS.includes(file)) {
return true
}
if (TEST_SUPPORT.test(file)) {
return false
}
return (
LAYOUT_ORACLE_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
LAYOUT_ORACLE_RUNTIME_MODULE.test(file)
)
}
@@ -1,207 +0,0 @@
#!/usr/bin/env node
// Benchmark: cost of the owner-routed settings selector per store write.
//
// getSettingsForRepoRuntimeOwner() is called from useShallow selectors at ~43
// sites across PullRequestPage and TaskPage. Zustand re-runs every subscribed
// selector on every store write, so before the fix each unrelated write
// allocated one settings-sized object per row and then shallow-compared every
// field to conclude nothing had changed.
//
// The fix caches by repo id and reuses the reference while the settings object,
// repo list, and resolved owner are unchanged, so useShallow's equality check
// short-circuits on Object.is.
//
// The selector body is mirrored here (node cannot import the .ts source, matching
// the sibling benchmarks). The settings field count is read from the real
// GlobalSettings type so a drifted shape fails loudly instead of flattering the
// result with a stale, smaller object.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const TYPES_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/types.ts', import.meta.url)),
'utf8'
)
function countGlobalSettingsFields(source) {
const block = source.match(/export type GlobalSettings = \{([\s\S]*?)\n\}/)
if (!block) {
throw new Error('types.ts no longer declares GlobalSettings in the expected shape')
}
const fields = block[1].match(/^\s{2}\w+\??:/gm) ?? []
if (fields.length < 50) {
throw new Error(`GlobalSettings parsed as only ${fields.length} fields; re-sync this benchmark`)
}
return fields.length
}
const SETTINGS_FIELDS = countGlobalSettingsFields(TYPES_SOURCE)
const ROWS = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_ROWS ?? '43', 10)
const WRITES = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WRITES ?? '2000', 10)
const WARMUP = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_OWNER_SETTINGS_BENCH_ROWS', ROWS],
['ORCA_OWNER_SETTINGS_BENCH_WRITES', WRITES],
['ORCA_OWNER_SETTINGS_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function makeSettings() {
const settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
for (let index = 0; index < SETTINGS_FIELDS - 1; index += 1) {
settings[`field${index}`] = index % 3 === 0 ? `value-${index}` : index % 3 === 1 ? index : true
}
return settings
}
function makeRepos(rows) {
return Array.from({ length: rows }, (_value, index) => ({
id: `repo-${index}`,
connectionId: null,
executionHostId: `runtime:env-${index % 4}`
}))
}
function resolveEnvironmentId(state, repoId) {
if (!repoId) {
return null
}
const matching = state.repos.filter((entry) => entry.id === repoId)
const repo = matching.length === 1 ? matching[0] : null
const hasOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
if (repo && hasOwner) {
const hostId = repo.executionHostId ?? ''
return hostId.startsWith('runtime:') ? hostId.slice('runtime:'.length) : null
}
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
// Pre-fix: a fresh object every call.
function selectorBefore(state, repoId) {
return { ...state.settings, activeRuntimeEnvironmentId: resolveEnvironmentId(state, repoId) }
}
// Post-fix: reuse the reference while nothing it derives from changed.
function makeCachedSelector() {
const cache = new Map()
return (state, repoId) => {
const environmentId = resolveEnvironmentId(state, repoId)
const cacheKey = repoId ?? ''
const cached = cache.get(cacheKey)
if (
cached &&
cached.settingsSource === state.settings &&
cached.reposSource === state.repos &&
cached.environmentId === environmentId
) {
return cached.value
}
const value = { ...state.settings, activeRuntimeEnvironmentId: environmentId }
cache.set(cacheKey, {
settingsSource: state.settings,
reposSource: state.repos,
environmentId,
value
})
if (cache.size > 256) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
return value
}
}
// Mirror of zustand's useShallow comparison.
function shallowEqual(a, b) {
if (Object.is(a, b)) {
return true
}
if (a === null || b === null || a === undefined || b === undefined) {
return false
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) {
return false
}
for (const key of keysA) {
if (!Object.is(a[key], b[key])) {
return false
}
}
return true
}
// One unrelated store write: every subscribed row re-runs its selector and the
// result is compared against the previous one to decide whether to re-render.
function simulateWrite(selector, state, rows, previous) {
let changed = 0
for (let row = 0; row < rows; row += 1) {
const next = selector(state, `repo-${row}`)
if (!shallowEqual(previous[row], next)) {
changed += 1
}
previous[row] = next
}
return changed
}
function measure(selector, state, rows) {
const previous = Array.from({ length: rows }, () => null)
for (let index = 0; index < WARMUP; index += 1) {
simulateWrite(selector, state, rows, previous)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < WRITES; index += 1) {
simulateWrite(selector, state, rows, previous)
}
samples.push((performance.now() - start) / WRITES)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const settings = makeSettings()
const rowCounts = [1, 10, ROWS, 100]
const rows = []
for (const rowCount of rowCounts) {
const state = { repos: makeRepos(Math.max(rowCount, ROWS)), settings }
const cached = makeCachedSelector()
// Equivalence: both selectors must produce the same value for every row.
for (let row = 0; row < rowCount; row += 1) {
const before = selectorBefore(state, `repo-${row}`)
const after = cached(state, `repo-${row}`)
if (!shallowEqual(before, after)) {
throw new Error(`selector mismatch at repo-${row}`)
}
}
rows.push({
rowCount,
beforeMs: measure(selectorBefore, state, rowCount),
afterMs: measure(makeCachedSelector(), state, rowCount)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log(`Owner-routed settings selector, per unrelated store write`)
console.log(
`GlobalSettings fields=${SETTINGS_FIELDS} writes=${WRITES} warmup=${WARMUP} (median of 5 rounds)`
)
console.log(`${pad('rows', 6)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`)
for (const row of rows) {
console.log(
`${pad(row.rowCount, 6)} ${pad(row.beforeMs.toFixed(4), 11)} ${pad(row.afterMs.toFixed(4), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
)
}
console.log(
`\nrows = subscribed selector call sites on screen (~${ROWS} across PullRequestPage/TaskPage).\nThe cost is paid on EVERY store write, including writes that touch nothing\nthese selectors read.`
)
@@ -192,6 +192,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/opencode/opencode-acp-stored-messages.test.ts',
'src/main/orcad/orcad-automations.test.ts',
'src/main/orcad/orcad-local-serve-selection.test.ts',
'src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts',
'src/main/orcad/serve-profile-ssh-targets.test.ts',
'src/main/pi/rpc-launch-resolution.test.ts',
'src/main/runtime/agent-session-conversation-clear-commit.test.ts',
-72
View File
@@ -1,72 +0,0 @@
# Verified pairing links
Orca Mobile pairs from `https://www.onorca.dev/pair#<code>`. The pairing code is in the URL
fragment, which browsers never send to a server. iOS and Android open that link in Orca Mobile only
after they have fetched and verified the two files in this directory from `www.onorca.dev`. Links
that use the `orca://` scheme no longer carry a code into the app, because any app can claim that
scheme.
The marketing site that serves `www.onorca.dev` is not in this repository, so these files must be
deployed there by hand.
## What to deploy
| File in this directory | Serve at | Content-Type |
| ---------------------------- | --------------------------------------------------------------- | ------------------ |
| `apple-app-site-association` | `https://www.onorca.dev/.well-known/apple-app-site-association` | `application/json` |
| `assetlinks.json` | `https://www.onorca.dev/.well-known/assetlinks.json` | `application/json` |
| `pair.html` | `https://www.onorca.dev/pair` | `text/html` |
Both `.well-known` files must:
- return `200` directly, with **no redirect**. The apex `onorca.dev` redirects to `www`, which is
why the links use `www.onorca.dev`. Neither OS follows a redirect while verifying.
- need **no authentication**, cookie, bot challenge, or other interstitial. Exempt `/.well-known/`
from any Cloudflare bot or challenge rule.
- be served with `Content-Type: application/json`. The Apple file has no extension, so most hosts
serve it as `application/octet-stream` unless a header rule overrides that.
`pair.html` is the page people see when the app is not installed, is too old to claim the link, or
the OS has not verified the domain yet. It explains how to pair instead, and can copy the code from
the fragment for the paste flow, which older app versions also accept. Do not add analytics or any
third-party script to this page: a script on it could read the code from the fragment.
## Placeholders to fill in before deploying
- `apple-app-site-association`: replace `REPLACE_WITH_APPLE_TEAM_ID` with the Apple Developer Team
ID (the `APPLE_TEAM_ID` secret used by `mobile/fastlane`).
- `assetlinks.json`: replace `REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256` with the SHA-256 fingerprint
of the certificate that signs the shipped APK. If the app is published on Google Play, use the
**app signing key** fingerprint from Play Console → App integrity, not the upload key. You can list
more than one fingerprint, for example both the Play key and a key used for direct APK downloads.
The direct-download APK built by `.github/workflows/mobile-android-release.yml` is currently signed
with the build template's public debug keystore. Do not list that fingerprint (`FA:C6:17:45:…`): it
is public, so any app signed with it could claim the links.
`src/shared/verified-pairing-link-files.test.ts` fails if it appears.
## App side
- Android: `mobile/app.config.js` adds an `autoVerify` intent filter for `https://www.onorca.dev/pair`.
Android checks `assetlinks.json` when the app is installed or updated.
- iOS: the associated-domains entitlement is opt-in, because an App Store archive with it fails to
sign until the App ID has the capability. To turn it on:
1. In the Apple Developer portal, enable **Associated Domains** on the App ID
`com.stably.orca.mobile`. The release lane regenerates the provisioning profile on every run.
2. Set the GitHub repository variable `ORCA_IOS_ASSOCIATED_DOMAINS` to `1`. The iOS release
workflow then builds with `applinks:www.onorca.dev`.
The host, the path, and the app identifiers are mirrored in `src/shared/verified-pairing-link.ts`,
`mobile/app.config.js`, and the files in this directory. `src/shared/verified-pairing-link-files.test.ts`
fails if they drift apart.
## Checking a deployment
```sh
curl -sI https://www.onorca.dev/.well-known/apple-app-site-association # 200, application/json
curl -sI https://www.onorca.dev/.well-known/assetlinks.json # 200, application/json
```
Apple fetches the file through its own CDN, which can lag by up to a day:
`https://app-site-association.cdn-apple.com/a/v1/www.onorca.dev`. On an Android device, run
`adb shell pm get-app-links com.stably.orca.mobile` and look for `www.onorca.dev: verified`.
@@ -1,15 +0,0 @@
{
"applinks": {
"details": [
{
"appIDs": ["REPLACE_WITH_APPLE_TEAM_ID.com.stably.orca.mobile"],
"components": [
{
"/": "/pair",
"comment": "Orca Mobile pairing link. The pairing code is in the fragment."
}
]
}
]
}
}
-10
View File
@@ -1,10 +0,0 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.stably.orca.mobile",
"sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256"]
}
}
]
-155
View File
@@ -1,155 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex" />
<!-- The pairing code is in the URL fragment: never load third-party scripts on this page. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self'"
/>
<title>Pair Orca Mobile</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #111111;
--muted: #5c5c5c;
--border: #e2e2e2;
--button-bg: #111111;
--button-fg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--fg: #f2f2f2;
--muted: #a3a3a3;
--border: #2c2c2c;
--button-bg: #f2f2f2;
--button-fg: #111111;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font:
16px/1.5 -apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
main {
max-width: 520px;
margin: 0 auto;
padding: 48px 16px;
}
h1 {
font-size: 24px;
margin: 0 0 8px;
}
p,
li {
color: var(--muted);
}
ol {
padding-left: 20px;
}
li {
margin-bottom: 12px;
}
strong {
color: var(--fg);
}
button {
margin-top: 8px;
padding: 10px 16px;
border: 0;
border-radius: 8px;
background: var(--button-bg);
color: var(--button-fg);
font: inherit;
font-weight: 600;
}
input {
width: 100%;
box-sizing: border-box;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--fg);
font:
12px ui-monospace,
monospace;
}
.note {
border-top: 1px solid var(--border);
padding-top: 16px;
font-size: 14px;
}
[hidden] {
display: none;
}
</style>
</head>
<body>
<main>
<h1>Pair Orca Mobile</h1>
<p>
This link pairs your phone with Orca on your computer. It opens in the Orca app when Orca
Mobile is installed and up to date.
</p>
<p><strong>Seeing this page instead?</strong></p>
<ol>
<li>Update Orca Mobile, then open the link again.</li>
<li>
Or open Orca Mobile, tap <strong>Pair</strong>, and scan the QR code shown in Orca on your
computer under <strong>Settings → Mobile</strong>.
</li>
<li id="copy-step" hidden>
Or copy the pairing code and paste it in Orca Mobile under
<strong>Pair → Paste code</strong>.
<br />
<button id="copy-button" type="button">Copy pairing code</button>
<input id="code-fallback" type="text" readonly hidden aria-label="Pairing code" />
</li>
</ol>
<p>Don't have the app? <a href="/download">Download Orca Mobile</a>.</p>
<p class="note">
Treat the pairing code like a password. It is in the part of this link after “#”, which your
browser keeps on this device and never sends to onorca.dev.
</p>
</main>
<script>
;(function () {
var code = window.location.hash.slice(1)
if (!code) {
return
}
var step = document.getElementById('copy-step')
var button = document.getElementById('copy-button')
var fallback = document.getElementById('code-fallback')
step.hidden = false
function showFallback() {
fallback.value = code
fallback.hidden = false
fallback.select()
}
button.addEventListener('click', function () {
if (!navigator.clipboard) {
showFallback()
return
}
navigator.clipboard.writeText(code).then(function () {
button.textContent = 'Copied'
}, showFallback)
})
})()
</script>
</body>
</html>
+1 -1
View File
@@ -264,7 +264,7 @@ Use this rubric when reviewing any Orca IDE screen, screenshot, or prototype. A
- **Layout density:** avoid jamming controls together. Preserve breathing room around the primary workflow, reduce competing buttons, and keep toolbar groups visually distinct. Dense screens are acceptable only when grouping, alignment, and hierarchy make scanning faster.
- **Cards and containers:** cards must be visually distinct from their parent surface through the existing `card`/`border` treatment. Avoid nesting cards inside cards. If a section is not a repeated item, modal, or framed tool, consider an unframed layout or full-width band instead.
- **Side-by-side layouts:** default to row-by-row layouts for complex workflows because they are easier to align and scan. Use side-by-side layouts only when space is constrained or comparison is the point, then polish column widths, baselines, and wrapping states carefully.
- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay.
- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay. Scripted scrolls ignore CSS, so pass them through `motionSafeScrollBehavior()`.
- **SSH and latency:** assume actions may run remotely. Disable submit controls immediately, delay visible loading feedback when appropriate, and keep focus stable while remote data arrives.
### 1. Match in-flight feedback to perceived duration
-25
View File
@@ -11,37 +11,12 @@
const APS_ENVIRONMENT =
process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development'
// Verified pairing links. Mirrors src/shared/verified-pairing-link.ts and config/verified-links/;
// src/shared/verified-pairing-link-files.test.ts fails if they drift.
const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev'
const VERIFIED_PAIRING_LINK_PATH = '/pair'
// Why opt-in: an App Store archive with this entitlement fails to sign until the App ID has the
// Associated Domains capability (config/verified-links/README.md).
const IOS_ASSOCIATED_DOMAINS = process.env.ORCA_IOS_ASSOCIATED_DOMAINS === '1'
module.exports = ({ config }) => ({
...config,
ios: {
...config.ios,
...(IOS_ASSOCIATED_DOMAINS
? { associatedDomains: [`applinks:${VERIFIED_PAIRING_LINK_HOST}`] }
: {}),
entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT }
},
android: {
...config.android,
intentFilters: [
...(config.android?.intentFilters ?? []),
{
action: 'VIEW',
autoVerify: true,
data: [
{ scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH }
],
category: ['BROWSABLE', 'DEFAULT']
}
]
},
plugins: (config.plugins ?? []).map((plugin) =>
plugin === 'expo-notifications'
? [
-6
View File
@@ -1,6 +0,0 @@
import { redirectIncomingPairingLink } from '../src/transport/incoming-pairing-link'
// Why: rewrite before routing, so no OS-delivered custom-scheme link reaches a screen that reads a code.
export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
return redirectIncomingPairingLink(path) ?? path
}
+30 -1
View File
@@ -4,10 +4,11 @@ import { readNativeNotificationData } from '../src/notifications/native-notifica
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
import { useCallback, useEffect, useRef } from 'react'
import { View, StyleSheet } from 'react-native'
import { Stack, useGlobalSearchParams, usePathname } from 'expo-router'
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
@@ -21,6 +22,7 @@ import {
import { startPushTokenSync } from '../src/notifications/push-registration'
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
@@ -41,6 +43,7 @@ Notifications.setNotificationHandler({
})
export default function RootLayout() {
const router = useRouter()
const pathname = usePathname()
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
useEffect(() => {
@@ -67,6 +70,32 @@ export default function RootLayout() {
useEffect(() => appUpdateChecker.start(), [])
useEffect(() => startAndroidForegroundPushPresentation(), [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
// covers cold-start (link tapped while app was closed); the listener
// covers warm-start (link tapped while app is in memory).
useEffect(() => {
function handleUrl(url: string) {
const code = extractPairingCodeFromUrl(url)
if (code) {
// Why: Android camera launches can leave Expo Router's unmatched
// `orca://pair` route underneath this screen; replacing keeps cancel
// and edge-back from revealing the router error page.
router.replace({ pathname: '/pair-confirm', params: { code } })
}
}
void Linking.getInitialURL().then((url) => {
if (url) {
handleUrl(url)
}
})
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
return () => sub.remove()
}, [router])
// ─── Notification tap routing ───
// Why: iOS delivers local notification taps through expo-notifications,
// not Linking. Route both cold-start and warm-start responses to the host
+2 -43
View File
@@ -3,10 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } fro
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import {
describePairingHost,
resolvePairConfirmRouteState
} from '../src/transport/pair-confirm-state'
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
import {
startPreProfilePairing,
type PreProfilePairingAttempt
@@ -46,7 +43,6 @@ export default function PairConfirmScreen() {
const routeState = resolvePairConfirmRouteState(params.code)
const offer = routeState.offer
const host = offer ? describePairingHost(offer) : null
const resolvedStatus =
status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status
const resolvedErrorMessage =
@@ -156,24 +152,8 @@ export default function PairConfirmScreen() {
<>
<Text style={styles.title}>Pair with this desktop?</Text>
<Text style={styles.subtitle}>
Check that this address matches the one under the QR code in Orca on your computer.
You opened a pairing link from your desktop. Confirm to add it to your hosts.
</Text>
{host && (
<View style={styles.hostDetails}>
<Text style={styles.detailLabel}>Address</Text>
<Text style={styles.detailValue} selectable>
{host.endpoint}
</Text>
{host.keyFingerprint && (
<>
<Text style={styles.detailLabel}>Host key</Text>
<Text style={styles.detailValue} selectable>
{host.keyFingerprint}
</Text>
</>
)}
</View>
)}
<View style={styles.actionStack}>
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
<Text style={styles.primaryButtonText}>Pair</Text>
@@ -254,27 +234,6 @@ const styles = StyleSheet.create({
maxWidth: 520,
alignSelf: 'center'
},
hostDetails: {
width: '100%',
maxWidth: 360,
alignSelf: 'center',
backgroundColor: colors.bgRaised,
borderColor: colors.borderSubtle,
borderWidth: 1,
borderRadius: radii.card,
padding: spacing.md,
marginBottom: spacing.xl
},
detailLabel: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
detailValue: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.bodySize,
marginBottom: spacing.sm
},
actionStack: {
width: '100%',
maxWidth: 360,
+5 -30
View File
@@ -9,10 +9,9 @@ import {
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import { PAIR_SCAN_LINK_REFUSED_NOTICE } from '../src/transport/incoming-pairing-link'
import {
startPreProfilePairing,
type PreProfilePairingAttempt
@@ -35,18 +34,6 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
const SCAN_RETICLE_SCALE = 0.62
const SCAN_RETICLE_MAX_SIZE = 360
function LinkRefusedNotice({ notice }: { notice: string | undefined }) {
if (notice !== PAIR_SCAN_LINK_REFUSED_NOTICE) {
return null
}
return (
<Text style={styles.notice}>
That orca:// link can’t pair your phone, because any app can open orca:// links. Scan the QR
code on your computer instead.
</Text>
)
}
function Step({ number, text }: { number: number; text: string }) {
return (
<View style={styles.step}>
@@ -62,7 +49,6 @@ export default function PairScanScreen() {
const router = useRouter()
const refreshHostClient = useRefreshHostClient()
const insets = useSafeAreaInsets()
const { notice } = useLocalSearchParams<{ notice?: string }>()
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
const [errorMessage, setErrorMessage] = useState('')
@@ -74,15 +60,6 @@ export default function PairScanScreen() {
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
// Why: a refused link can cold-start the app on this screen, with nothing to go back to.
const goBack = useCallback(() => {
if (router.canGoBack()) {
router.back()
} else {
router.replace('/')
}
}, [router])
const setPairScanRootRef = useCallback((node: View | null): void => {
if (node !== null) {
mountedRef.current = true
@@ -241,10 +218,9 @@ export default function PairScanScreen() {
const canAskAgain = permission.canAskAgain !== false
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={goBack}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<LinkRefusedNotice notice={notice} />
<View style={styles.centered}>
<Text style={styles.title}>
{canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'}
@@ -275,7 +251,7 @@ export default function PairScanScreen() {
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="Paste the pairing link or code"
placeholder="orca://pair?code=... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
@@ -285,11 +261,10 @@ export default function PairScanScreen() {
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={goBack}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<LinkRefusedNotice notice={notice} />
<View style={styles.steps}>
<Step number={1} text="Open Orca on your computer" />
<Step number={2} text="Go to Settings → Mobile" />
@@ -374,7 +349,7 @@ export default function PairScanScreen() {
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="Paste the pairing link or code"
placeholder="orca://pair?code=... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
+84 -3
View File
@@ -1,6 +1,87 @@
import { Redirect } from 'expo-router'
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
// Why: +native-intent rewrites OS links before routing; this route never reads a pairing code.
export default function PairRedirectScreen() {
return <Redirect href="/pair-scan" />
const router = useRouter()
const params = useLocalSearchParams<{ code?: string }>()
const [missingCode, setMissingCode] = useState(false)
const goHome = useCallback(() => {
router.replace('/')
}, [router])
useEffect(() => {
let disposed = false
async function redirectToConfirm() {
const codeParam = Array.isArray(params.code) ? params.code[0] : params.code
if (codeParam) {
router.replace({ pathname: '/pair-confirm', params: { code: codeParam } })
return
}
const initialUrl = await Linking.getInitialURL().catch(() => null)
const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null
if (disposed) {
return
}
if (code) {
router.replace({ pathname: '/pair-confirm', params: { code } })
return
}
setMissingCode(true)
}
void redirectToConfirm()
return () => {
disposed = true
}
}, [params.code, router])
return (
<View style={styles.container}>
{missingCode ? (
<>
<Text style={styles.errorText}>Missing pairing code</Text>
<Pressable style={styles.primaryButton} onPress={goHome}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
</>
) : (
<ActivityIndicator size="large" color={colors.textSecondary} />
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase,
padding: spacing.lg
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
lineHeight: 20,
marginBottom: spacing.xl,
textAlign: 'center'
},
primaryButton: {
alignItems: 'center',
backgroundColor: colors.textPrimary,
borderRadius: radii.button,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
})
-11
View File
@@ -15,17 +15,6 @@ export const pairScanStyles = StyleSheet.create({
justifyContent: 'center',
marginBottom: spacing.sm
},
notice: {
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderColor: colors.borderSubtle,
borderWidth: 1,
borderRadius: radii.card,
fontSize: typography.bodySize,
lineHeight: 20,
padding: spacing.md,
marginBottom: spacing.lg
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
@@ -1,109 +0,0 @@
import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL'
import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url'
import { describe, expect, it } from 'vitest'
import { redirectIncomingPairingLink } from './incoming-pairing-link'
// The oracle is the router itself: what its linking reads from an OS link after the hook passes it on.
function routerParams(url: string): URLSearchParams | null {
try {
return parseUrlUsingCustomBase(extractExpoPathFromURL([], url)).searchParams
} catch {
return null
}
}
const TARGETS = [
'orca://pair-confirm?code=SECRET',
'orca://pair?code=SECRET',
'orca://pair-confirm?a=1&code=SECRET',
'orca:///x/../pair-confirm?code=SECRET',
'orca://(group)/pair-confirm?code=SECRET',
'orca://h/host-1/session/wt-1?code=SECRET',
'https://evil.example/pair-confirm?code=SECRET',
'exp+orca-mobile://pair-confirm?code=SECRET'
]
// Tab, LF and CR are removed anywhere; C0 controls and space are trimmed from the ends.
const STRIPPED = ['\t', '\n', '\r']
const TRIMMED = ['\u0000', '\u001f', ' ']
function* mutations(url: string): Generator<string> {
yield url
yield url.toUpperCase()
yield url.replace('code', 'CODE')
yield url.replace('code', '%63ode')
yield url.replace('code', 'code')
yield url.replace('pair-confirm', 'pair%2Dconfirm')
yield url.replace('?code=', '?code%3D')
yield url.replace('&code=', '%26code=')
for (const character of STRIPPED) {
for (let index = 0; index <= url.length; index++) {
yield url.slice(0, index) + character + url.slice(index)
}
yield url.replace('code', `co${encodeURIComponent(character)}de`)
}
for (const character of TRIMMED) {
yield `${character}${url}${character}`
}
}
function* wrapped(url: string): Generator<string> {
for (const depth of [0, 1, 2, 7]) {
for (const layers of [0, 1, 2, 3]) {
let current = url
for (let level = 0; level < depth; level++) {
let inner = current
for (let layer = 0; layer < layers; layer++) {
inner = encodeURIComponent(inner)
}
current = `orca://expo-development-client/?url=${inner}`
}
yield current
if (depth === 0) {
break
}
}
}
}
function* corpus(): Generator<string> {
for (const target of TARGETS) {
for (const mutated of mutations(target)) {
yield* wrapped(mutated)
}
}
}
describe('redirectIncomingPairingLink agrees with the router', () => {
it('never passes on a link from which the router would read a code', () => {
let checked = 0
for (const url of corpus()) {
for (const allowDevClientLinks of [true, false]) {
if (redirectIncomingPairingLink(url, allowDevClientLinks) === null) {
const params = routerParams(url)
expect(params, url).not.toBeNull()
expect(params?.has('code'), JSON.stringify(url)).toBe(false)
}
}
checked++
}
expect(checked).toBeGreaterThan(1000)
}, 60_000)
it('refuses every development-client wrapper in a release build', () => {
for (const url of corpus()) {
if (url.toLowerCase().includes('orca://expo-development-client')) {
expect(redirectIncomingPairingLink(url, false), JSON.stringify(url)).not.toBeNull()
}
}
}, 60_000)
it('still lets ordinary links and the dev launcher through', () => {
expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1', false)).toBeNull()
expect(
redirectIncomingPairingLink(
`orca://expo-development-client/?url=${encodeURIComponent('http://192.168.1.5:8081')}`,
true
)
).toBeNull()
})
})
@@ -1,77 +0,0 @@
import { describe, expect, it } from 'vitest'
import { PAIR_SCAN_LINK_REFUSED_NOTICE, redirectIncomingPairingLink } from './incoming-pairing-link'
const refused = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}`
describe('redirectIncomingPairingLink', () => {
it('sends a verified https link to the confirm screen with the fragment code', () => {
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair#abc_1-2')).toBe(
'/pair-confirm?code=abc_1-2'
)
})
it('refuses a pairing code carried by the custom scheme', () => {
for (const url of [
'orca://pair?code=abc123',
'orca://pair#abc123',
'ORCA://PAIR?code=abc123',
'orca://pair/?code=abc123',
'orca:///pair?code=abc123',
'orca://pair-confirm?code=abc123',
'orca:///pair-confirm?code=abc123',
'exp+orca-mobile://pair-confirm?code=abc123',
'orca://pair%2Dconfirm?code=abc123',
'orca://./pair-confirm?code=abc123',
'orca://h/host-1?code=abc123',
'orca://pair-confirm?%63ode=abc123',
'orca://pair-confirm?CODE=abc123',
'orca:///ignored/../pair-confirm?code=abc123',
`orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?code=abc123')}`,
`exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(
encodeURIComponent('orca://pair-confirm?code=abc123')
)}`,
`orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?%2563ode=abc')}`,
`orca://expo-development-client/?junk=%ZZ&url=${encodeURIComponent('orca://pair-confirm?code=x')}`,
'orca://expo-development-client/?url=orca%3A%2F%2Fpair-confirm%3Fa%3D%E0%26code%3DSECRET',
'orca://expo-development-client/?url=orca://pair-confirm?a%3D%E0%26code%3DSECRET',
'orca://pair-confirm?code=SECRET#\u2029x',
'orca://pair-confirm?code=SECRET#\nx',
'orca://pair-confirm?code=SECRET#\u2028x',
'orca://expo-development-client/?url=orca://pair-confirm?co%09de=SECRET',
'orca://expo-development-client/?url=orca://pair-confirm?c%0Aode=SECRET',
'orca://expo-development-client/?url=orca://pair-confirm%3Fcode%0D=SECRET'
]) {
expect(redirectIncomingPairingLink(url), url).toBe(refused)
}
})
it('refuses a code nested in development-client wrappers at any depth', () => {
let url = 'orca://pair-confirm?code=abc123'
for (let depth = 0; depth < 12; depth++) {
url = `orca://expo-development-client/?url=${encodeURIComponent(url)}`
}
expect(redirectIncomingPairingLink(url)).toBe(refused)
})
it('opens the scanner for a bare custom-scheme pairing link', () => {
expect(redirectIncomingPairingLink('orca://pair')).toBe('/pair-scan')
expect(redirectIncomingPairingLink('orca://pair/')).toBe('/pair-scan')
})
it('refuses unverified https links that target a pairing screen', () => {
expect(redirectIncomingPairingLink('https://evil.example/pair#abc123')).toBe(refused)
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair-confirm?code=abc')).toBe(
refused
)
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair?code=abc')).toBe(refused)
})
it('leaves every other link to the router', () => {
expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1')).toBeNull()
expect(redirectIncomingPairingLink('orca://pairing')).toBeNull()
expect(redirectIncomingPairingLink('orca://h/host-1?notice=x#y')).toBeNull()
expect(redirectIncomingPairingLink('orca://pair-scan')).toBeNull()
expect(redirectIncomingPairingLink('orca://h/host-1/session/pair')).toBeNull()
expect(redirectIncomingPairingLink('/settings')).toBeNull()
})
})
@@ -1,80 +0,0 @@
import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL'
import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url'
import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link'
export const PAIR_SCAN_LINK_REFUSED_NOTICE = 'custom-scheme-link'
const REFUSED_PATH = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}`
const PAIRING_ROUTES = new Set(['pair', 'pair-confirm'])
const DEV_CLIENT_HOST = 'expo-development-client'
/** The router's own reading of an OS link: the same functions its linking uses, after this hook. */
export function resolveRouterTarget(url: string): {
pathname: string
searchParams: URLSearchParams
hash: string
} {
const parsed = parseUrlUsingCustomBase(extractExpoPathFromURL([], url))
return { pathname: parsed.pathname, searchParams: parsed.searchParams, hash: parsed.hash }
}
/**
* Rewrites an OS-delivered link before the router sees it. Null leaves the link to the router.
* Why: the OS hands an `orca://` URL to whichever app claimed the scheme, so a code it carries may
* already be in another app's hands. Only a verified https link may start pairing. The decision is
* made on the router's own parse, never a re-implementation, and anything unparseable is refused.
*/
export function redirectIncomingPairingLink(
url: string,
allowDevClientLinks: boolean = typeof __DEV__ !== 'undefined' && __DEV__
): string | null {
const verifiedCode = extractVerifiedPairingLinkCode(url)
if (verifiedCode) {
return `/pair-confirm?code=${encodeURIComponent(verifiedCode)}`
}
// Why: the router unwraps `expo-development-client/?url=` links; release builds have no use for them.
const outer = parseOuterUrl(url)
if (!allowDevClientLinks && outer?.hostname === DEV_CLIENT_HOST) {
return REFUSED_PATH
}
let target: ReturnType<typeof resolveRouterTarget>
try {
target = resolveRouterTarget(url)
} catch {
return REFUSED_PATH
}
if ([...target.searchParams.keys()].some((key) => key.toLowerCase() === 'code')) {
return REFUSED_PATH
}
// Pairing screens are top-level routes; groups like `(tabs)` don't count as segments.
const route = target.pathname
.split('/')
.map((segment) => safeDecode(segment).toLowerCase())
.find((segment) => segment !== '' && !/^\(.*\)$/.test(segment))
if (!route || !PAIRING_ROUTES.has(route)) {
return null
}
// Why the outer hash too: the router drops it, but a legacy `orca://pair#<code>` still earns the notice.
const carriesPayload =
[...target.searchParams.keys()].length > 0 ||
target.hash.length > 1 ||
(outer?.hash.length ?? 0) > 1
return carriesPayload ? REFUSED_PATH : '/pair-scan'
}
// Same check the router applies before unwrapping a development-client link.
function parseOuterUrl(url: string): URL | null {
try {
return new URL(url)
} catch {
return null
}
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
@@ -1,7 +1,7 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it } from 'vitest'
import { describePairingHost, resolvePairConfirmRouteState } from './pair-confirm-state'
import { resolvePairConfirmRouteState } from './pair-confirm-state'
import type { PairingOffer } from './types'
function encodeOffer(offer: PairingOffer): string {
@@ -52,28 +52,3 @@ describe('resolvePairConfirmRouteState', () => {
})
})
})
describe('describePairingHost', () => {
const publicKeyB64 = Buffer.alloc(32, 7).toString('base64')
it('shows the endpoint and a host key fingerprint before pairing', () => {
const host = describePairingHost({
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token-abc',
publicKeyB64
})
expect(host.endpoint).toBe('ws://192.168.1.10:6768')
expect(host.keyFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/)
})
it('omits the fingerprint for an unreadable key', () => {
const host = describePairingHost({
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token-abc',
publicKeyB64: 'pubkey-xyz'
})
expect(host.keyFingerprint).toBeNull()
})
})
@@ -1,4 +1,3 @@
import { deriveHostFingerprint } from '../notifications/push-host-fingerprint'
import { parsePairingCode } from './pairing'
import type { PairingOffer } from './types'
@@ -18,11 +17,3 @@ export function resolvePairConfirmRouteState(code: string | undefined): PairConf
return { kind: 'ready', offer, errorMessage: '' }
}
/** What the user checks against the desktop before pairing; a verified link proves the app, not the host. */
export function describePairingHost(offer: PairingOffer): {
endpoint: string
keyFingerprint: string | null
} {
return { endpoint: offer.endpoint, keyFingerprint: deriveHostFingerprint(offer.publicKeyB64) }
}
-12
View File
@@ -39,11 +39,6 @@ describe('pairing deep links', () => {
expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code')
})
it('extracts the code from a verified https pairing link fragment', () => {
expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair#abc123')).toBe('abc123')
expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair?code=abc123')).toBeNull()
})
it('ignores empty and unrelated URLs', () => {
expect(extractPairingCodeFromUrl('orca://pair')).toBeNull()
expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull()
@@ -68,13 +63,6 @@ describe('pairing deep links', () => {
expect(parsePairingCode(code)).toEqual(offer)
})
it('parses a pasted or scanned verified https pairing link', () => {
const link = `https://www.onorca.dev/pair#${encodeOffer()}`
expect(parsePairingCode(link)).toEqual(offer)
expect(decodePairingUrl(link)).toEqual(offer)
})
it('preserves a TLS reverse-proxy endpoint with an explicit port and path', () => {
const proxiedOffer = {
...offer,
+6 -11
View File
@@ -1,4 +1,3 @@
import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link'
import { PairingOfferSchema, type PairingOffer } from './types'
// Why: this file mirrors src/shared/pairing.ts (which is covered by CI
@@ -18,14 +17,10 @@ export function decodePairingUrl(url: string): PairingOffer | null {
}
}
// Why: in-app QR scan and paste never pass through the OS link dispatcher, so
// they keep accepting the custom scheme that old desktops emit. OS-delivered
// links go through incoming-pairing-link.ts, which refuses custom-scheme codes.
// Why: system camera apps hand us the raw custom-scheme URL. Keeping
// extraction here makes QR scan, paste, and external deep-link flows
// accept the same URL shapes.
export function extractPairingCodeFromUrl(url: string): string | null {
const verifiedCode = extractVerifiedPairingLinkCode(url)
if (verifiedCode) {
return verifiedCode
}
const trimmed = url.trim()
const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed)
if (!match) {
@@ -54,8 +49,8 @@ export function extractPairingCodeFromUrl(url: string): string | null {
return null
}
// Why: accept an `orca://pair?...` URL, a verified https link, or the bare
// base64 string so the paste-pair flow can take whichever the user actually
// Why: accept either an `orca://pair?...` URL or the bare base64
// string so the paste-pair flow can take whichever the user actually
// copied from desktop.
export function parsePairingCode(input: string): PairingOffer | null {
const trimmed = input.trim()
@@ -63,7 +58,7 @@ export function parsePairingCode(input: string): PairingOffer | null {
return null
}
try {
if (/^(?:orca|https):\/\//i.test(trimmed)) {
if (/^orca:\/\//i.test(trimmed)) {
return decodePairingUrl(trimmed)
}
return decodePairingBase64(trimmed)
-1
View File
@@ -330,7 +330,6 @@
"vite": "npm:rolldown-vite@7.3.1",
"vitest": "5.0.3",
"vscode-oniguruma": "^2.0.1",
"vscode-textmate": "^9.3.2",
"zustand": "^5.0.15"
},
"optionalDependencies": {
-8
View File
@@ -625,9 +625,6 @@ importers:
vscode-oniguruma:
specifier: ^2.0.1
version: 2.0.1
vscode-textmate:
specifier: ^9.3.2
version: 9.3.2
zustand:
specifier: ^5.0.15
version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))
@@ -7839,9 +7836,6 @@ packages:
vscode-oniguruma@2.0.1:
resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==}
vscode-textmate@9.3.2:
resolution: {integrity: sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -15627,8 +15621,6 @@ snapshots:
vscode-oniguruma@2.0.1: {}
vscode-textmate@9.3.2: {}
w3c-keyname@2.2.8: {}
web-namespaces@2.0.1: {}
@@ -116,6 +116,21 @@ describe('structured child-work ingest', () => {
expect(server.getStructuredChildWork(SUBJECT)).toEqual([first])
})
// A quit stops hook ingress before the chat teardown decides which chats to offer to resume,
// and that decision reads these records: a chat whose only work was a background command or a
// subagent was otherwise never offered.
it('keeps the children readable through a quit stop', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary(), SUBJECT)
server.ingestStructuredChildWork(SUBJECT, started, 'claude')
server.stop({ keepStatus: true })
expect(server.getStructuredChildWorkViews(SUBJECT)).toEqual([
expect.objectContaining({ description: 'Audit the build', state: 'working' })
])
server.stop()
expect(server.getStructuredChildWorkViews(SUBJECT)).toEqual([])
})
it('rejects an address that names no session', () => {
const server = new AgentHookServer()
expect(() =>
@@ -214,7 +214,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook
this.endpointFileWritten = false
}
stop(): void {
/** `keepStatus`: quit only. The chat teardown after it still reads each chat's subagents and
* background tasks from the status store to decide what to offer to resume; exit frees it. */
stop(options?: { keepStatus?: boolean }): void {
// Terminal status may still have a pending write while hook ingress is disabled.
if (this.statusHooksEnabled || this.statusPersistTimer) {
this.flushStatusPersistSync()
@@ -262,8 +264,10 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook
this.ownerStateInitialized = false
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
clearAllListenerCaches(this.state)
this.resetCanonicalStatus()
this.notifyStatusChangeListeners()
if (!options?.keepStatus) {
this.resetCanonicalStatus()
this.notifyStatusChangeListeners()
}
this.paneStatusClearListeners.clear()
this.statusDropListeners.clear()
this.statusChangeListeners.clear()
@@ -7,20 +7,14 @@ import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-requ
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
@@ -32,7 +26,6 @@ describe('Azure DevOps pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n',
stderr: ''
@@ -183,44 +176,6 @@ describe('Azure DevOps pull request creation', () => {
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n',
stderr: ''
}))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
globalThis.fetch = vi.fn(async () =>
Response.json({
pullRequestId: 38,
title: 'Remote Azure create',
status: 'active',
creationDate: '2026-06-01T00:00:00Z'
})
) as never
await expect(
createAzureDevOpsPullRequest(
'/remote/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/azure',
title: 'Remote Azure create'
},
'ssh:ssh-1'
)
).resolves.toMatchObject({
ok: true,
number: 38
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('classifies auth failures without retrying shell commands', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ message: 'Unauthorized' }, { status: 401 })
+4 -32
View File
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
@@ -15,19 +14,15 @@ import {
getAzureDevOpsRepoRefForRemote,
parseAzureDevOpsRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
describe('parseAzureDevOpsRepoRef', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetAzureDevOpsRepoRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
_resetAzureDevOpsRepoRefCache()
})
@@ -102,28 +97,6 @@ describe('parseAzureDevOpsRepoRef', () => {
expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull()
})
it('resolves repository refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
host: 'dev.azure.com',
organization: 'acme',
project: 'Project',
repository: 'repo',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('keeps local host and local WSL repository-ref cache entries separate', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({
@@ -165,14 +138,13 @@ describe('parseAzureDevOpsRepoRef', () => {
})
it('bounds cached repository refs for distinct repo paths', async () => {
sshExecMock.mockResolvedValue({
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
for (let i = 0; i < 513; i += 1) {
await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin', 'conn-1')
await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin')
}
expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512)
@@ -5,20 +5,14 @@ import {
} from './pull-request-creation'
import { _resetBitbucketRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
@@ -49,7 +43,6 @@ describe('Bitbucket pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://bitbucket.org/team/repo.git\n',
stderr: ''
+3 -28
View File
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
@@ -12,24 +11,18 @@ vi.mock('../git/runner', () => ({
import {
_getBitbucketRepoRefCacheSize,
_resetBitbucketRepoRefCache,
getBitbucketRepoRefForRemote,
getBitbucketRepoRef,
parseBitbucketRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
describe('Bitbucket repository refs', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetBitbucketRepoRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
afterEach(() => {})
it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => {
expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({
@@ -108,22 +101,4 @@ describe('Bitbucket repository refs', () => {
expect(_getBitbucketRepoRefCacheSize()).toBe(512)
})
it('resolves project refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({
stdout: 'git@bitbucket.org:remote/project.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({
workspace: 'remote',
repoSlug: 'project'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -1,17 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
readDocPreviewFile: vi.fn(),
requireSshFilesystemProvider: vi.fn()
callRuntimeEnvironment: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
vi.mock('../persistence', () => ({
getCanonicalUserDataPath: () => '/user-data'
}))
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
@@ -53,9 +50,6 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreview
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
mocks.requireSshFilesystemProvider.mockReturnValue({
readDocPreviewFile: mocks.readDocPreviewFile
})
})
describe('docPreviewContentType', () => {
@@ -68,131 +62,15 @@ describe('docPreviewContentType', () => {
})
describe('readDocPreviewFile — ssh owner', () => {
it('reads text through the SSH filesystem provider', async () => {
mocks.readDocPreviewFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
it('404s with the not-served message instead of reading anything', async () => {
const grant = sshGrant()
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith({
boundaryPath: '/home/alice/docs',
entryPath: '/home/alice/docs/index.html',
implicitRootPath: null,
authorizedRootPaths: ['/home/alice/docs'],
targetPath: '/home/alice/docs/index.html',
maxTextBytes: 10 * 1024 * 1024,
maxBinaryBytes: 10 * 1024 * 1024
})
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('decodes a base64 binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.readDocPreviewFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
// always whole and needs no truncation flag.
it('serves a whole SSH read that carries no truncation flag', async () => {
mocks.readDocPreviewFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
})
// 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 })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
})
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
// reporting it as a refused format would be a failure the workspace never reported.
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
mocks.readDocPreviewFile.mockResolvedValue({
content: '',
isBinary: true,
mimeType: 'image/png'
})
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
})
it('404s when the host cannot canonicalize the path at all', async () => {
mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
await expect(readDocPreviewFile(grant, 'index.html')).resolves.toMatchObject({
ok: false,
status: 404
})
expect(mocks.readDocPreviewFile).toHaveBeenCalledOnce()
})
it('404s a path outside the grant root without touching the provider', async () => {
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
})
it('requires approval for a sibling directory before touching the SSH provider', async () => {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
requestBase: '/home/alice',
root: '/home/alice/docs',
entryRelativePath: 'docs/index.html',
browserPageId: 'page-1'
})
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')
mocks.readDocPreviewFile.mockResolvedValue({ content: 'logo', isBinary: false })
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true })
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(
expect.objectContaining({
boundaryPath: '/home/alice',
implicitRootPath: '/home/alice/docs',
authorizedRootPaths: ['/home/alice/assets'],
targetPath: '/home/alice/assets/logo.png'
})
)
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readDocPreviewFile.mockRejectedValue(new Error('file_too_large'))
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
ok: false,
status: 413
})
})
it('404s when the provider read fails', async () => {
mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
ok: false,
status: 404
status: 404,
message: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
})
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
})
+4 -27
View File
@@ -3,9 +3,8 @@ import type { RuntimeFilePreviewResult } from '../../shared/runtime-file-contrac
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'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error'
import {
resolveDocPreviewAuthorityPaths,
resolveDocPreviewCandidatePath,
@@ -16,8 +15,6 @@ import {
} from './doc-preview-grant-registry'
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
/** 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.'
@@ -29,16 +26,12 @@ const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to
/** The paired host rejects an over-cap asset outright instead of clamping it. */
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
/** Same stance as the SSH relay message: previews fail closed on a host without scoped reads. */
/** Previews fail closed on a host without scoped reads. */
const RUNTIME_DOC_PREVIEW_UPDATE_REQUIRED_MESSAGE =
'Secure document previews require a newer Orca on the paired machine. Update it and try again.'
/** Both owners refuse an over-cap file; only their error shapes differ. */
function isTooLargeReadError(error: unknown): boolean {
return (
error instanceof FileReadCapExceededError ||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
)
return error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR
}
export type DocPreviewReadOutcome =
@@ -168,23 +161,7 @@ export async function readDocPreviewFile(
const contentType = docPreviewContentType(relativePath)
try {
if (grant.owner.kind === 'ssh') {
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
const authority = resolveDocPreviewAuthorityPaths(grant)
if (!authority.entryPath || !provider.readDocPreviewFile) {
return notFoundOutcome()
}
return toOutcome(
await provider.readDocPreviewFile({
boundaryPath: grant.requestBase,
entryPath: authority.entryPath,
implicitRootPath: authority.implicitRootPath,
authorizedRootPaths: authority.authorizedRootPaths,
targetPath: absolutePath,
maxTextBytes: DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES,
maxBinaryBytes: DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES
}),
contentType
)
return notFoundOutcome(SSH_HOST_NOT_SERVED_MESSAGE)
}
const runtimeOwner = grant.owner
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
@@ -8,14 +8,21 @@
// { steps: Step[], controlResponses?: { [subtype]: <response> },
// controlResponseLeadingFrames?: { [subtype]: <frame>[] } } where a Step is
// { emit: <frame> } | { awaitUserMessage: true } | { stderr: <text> } |
// { awaitControlResponse: <request_id> } | { delayMs: <n> } | { exit: <code> }
// { awaitControlResponse: <request_id> } | { awaitFile: { path, timeoutMs } } |
// { delayMs: <n> } | { exit: <code> }
// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written
// ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close
// ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer
// ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid
import { spawn } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createInterface } from 'node:readline'
import { setTimeout as delay } from 'node:timers/promises'
if (process.argv.includes('--version')) {
process.stdout.write('2.1.258 (Claude Code)\n')
process.exit(0)
}
const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH
const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH
@@ -23,6 +30,13 @@ const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH
const report = {
argv: process.argv.slice(1),
execPath: process.execPath,
cwd: process.cwd(),
env: Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
key.startsWith('ORCA_') || ['HOME', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].includes(key)
)
),
controlRequests: [],
controlResponses: [],
userMessages: [],
@@ -136,6 +150,14 @@ for (const step of scenario.steps) {
await waitFor('user')
} else if (step.awaitControlResponse !== undefined) {
await waitFor('control_response', step.awaitControlResponse)
} else if (step.awaitFile) {
const deadline = Date.now() + step.awaitFile.timeoutMs
while (!existsSync(step.awaitFile.path)) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for test acknowledgement: ${step.awaitFile.path}`)
}
await delay(10)
}
} else if (step.delayMs) {
await new Promise((resolve) => setTimeout(resolve, step.delayMs))
} else if (step.exit !== undefined) {
+3 -1
View File
@@ -5,8 +5,10 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(async () => {})
}))
vi.mock('./ssh/ssh-target-registry', () => ({
connectRegisteredSshTarget: mocks.connect
}))
vi.mock('./ipc/ssh', () => ({
connectRegisteredSshTarget: mocks.connect,
getSshConnectionStore: () => ({
upsertRuntimeOwnedTarget: (runtimeId: string) => ({
id: `runtime-ssh-${runtimeId}`,
+2 -1
View File
@@ -1,4 +1,5 @@
import { connectRegisteredSshTarget, getSshConnectionStore } from './ipc/ssh'
import { getSshConnectionStore } from './ipc/ssh'
import { connectRegisteredSshTarget } from './ssh/ssh-target-registry'
import {
disconnectRegisteredSshTarget,
removeRegisteredSshTarget
-15
View File
@@ -1,9 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import {
clearGitCapabilityStateForTests,
getLocalGitCapabilityCache,
getSshGitCapabilityCache,
withLocalGitCapabilityCacheForExecution
} from './git-capability-state'
import {
@@ -11,9 +9,6 @@ import {
seedWslLinkedWorktreeGitRoutingForTests
} from './wsl-linked-worktree-git-routing'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cache keys providers by reference only and never calls a method on them.
const createProviderIdentity = (): SshGitProvider => ({}) as SshGitProvider
describe('Git capability execution-host state', () => {
beforeEach(() => {
clearGitCapabilityStateForTests()
@@ -46,16 +41,6 @@ describe('Git capability execution-host state', () => {
).toBe(true)
})
it('shares one SSH provider lifetime without leaking into a replacement provider', () => {
const provider = createProviderIdentity()
const replacementProvider = createProviderIdentity()
expect(getSshGitCapabilityCache(provider)).toBe(getSshGitCapabilityCache(provider))
expect(getSshGitCapabilityCache(provider)).not.toBe(
getSshGitCapabilityCache(replacementProvider)
)
})
it('uses native capability state for a prepared host-routed WSL worktree', async () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
-14
View File
@@ -1,5 +1,4 @@
import { GitCapabilityCache } from '../../shared/git-capability-cache'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
isWslLinkedWorktreeGitRoutingCandidate,
@@ -14,9 +13,6 @@ type LocalGitCapabilityTarget = {
const localCapabilitiesByExecutionHost = new Map<string, GitCapabilityCache>()
const MAX_LOCAL_GIT_CAPABILITY_HOSTS = 128
// Why: reconnecting creates a new provider, while concurrent IPC/runtime users
// of one SSH connection must share the same remote Git capability results.
let sshCapabilitiesByProvider = new WeakMap<SshGitProvider, GitCapabilityCache>()
function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string {
const wslDistro =
@@ -66,16 +62,6 @@ export function withLocalGitCapabilityCacheForExecution<T>(
)
}
export function getSshGitCapabilityCache(provider: SshGitProvider): GitCapabilityCache {
let cache = sshCapabilitiesByProvider.get(provider)
if (!cache) {
cache = new GitCapabilityCache()
sshCapabilitiesByProvider.set(provider, cache)
}
return cache
}
export function clearGitCapabilityStateForTests(): void {
localCapabilitiesByExecutionHost.clear()
sshCapabilitiesByProvider = new WeakMap()
}
-21
View File
@@ -1,4 +1,3 @@
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
import { parseHostedRemote } from './hosted-remote-url'
import { resolveDefaultBaseRefViaExec } from './repo'
@@ -82,26 +81,6 @@ function normalizeConfiguredLogin(value: string): string {
*/
export type ResolvedGitUsername = { username: string; authoritative: boolean }
export async function getSshGitUsername(
provider: SshGitProvider,
repoPath: string
): Promise<string> {
// Why: SSH targets cannot rely on the local `gh` account, and git email/name
// are author identity rather than hosted-account usernames.
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
try {
const { stdout } = await provider.exec(['config', '--get', key], repoPath)
const username = normalizeConfiguredLogin(stdout)
if (username) {
return username
}
} catch {
// Missing config keys are expected; try the next explicit username key.
}
}
return ''
}
type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean }
type GhLoginOutcome = { login: string; timedOut: boolean }
+4 -72
View File
@@ -1,17 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { getSshGitProviderMock, getSshGitProviderGenerationMock, gitExecFileAsyncMock } = vi.hoisted(
() => ({
getSshGitProviderMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
gitExecFileAsyncMock: vi.fn()
})
)
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
@@ -35,9 +25,6 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
describe('remote ref probe cache (P1-D)', () => {
beforeEach(() => {
getSshGitProviderMock.mockReset()
getSshGitProviderGenerationMock.mockReset()
getSshGitProviderGenerationMock.mockReturnValue(0)
gitExecFileAsyncMock.mockReset()
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
@@ -89,27 +76,6 @@ describe('remote ref probe cache (P1-D)', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('does not let a lookup on a reconnected provider join the old connection probe', async () => {
const cache = createRemoteRefProbeCache(parseExampleRef)
let releaseStalled = (): void => {}
const execMock = vi
.fn()
.mockImplementationOnce(
async () => await new Promise((resolve) => (releaseStalled = () => resolve({ stdout: '' })))
)
.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' })
getSshGitProviderMock.mockReturnValue({ exec: execMock })
const stalled = cache.get('/repo', 'origin', 'conn-1')
getSshGitProviderGenerationMock.mockReturnValue(1)
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' })
expect(execMock).toHaveBeenCalledTimes(2)
releaseStalled()
await expect(stalled).resolves.toBeNull()
})
it('does not let a probe abandoned as stale overwrite its successor answer', async () => {
const cache = createRemoteRefProbeCache(parseExampleRef)
const stalled = deferred<{ stdout: string }>()
@@ -143,46 +109,12 @@ describe('remote ref probe cache (P1-D)', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('never caches a miss from a runtime that could not be asked at all', async () => {
it('answers an SSH repo with null without running git on this machine', async () => {
const cache = createRemoteRefProbeCache(parseExampleRef)
getSshGitProviderMock.mockReturnValueOnce(undefined)
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
// The provider was gone, not the remote: reconnecting must not need the TTL.
getSshGitProviderMock.mockReturnValue({
exec: vi.fn(async () => ({ stdout: 'git@example.com:team/repo.git\n' }))
})
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' })
})
it('holds an SSH repo that has no such remote instead of re-asking every poll', async () => {
const cache = createRemoteRefProbeCache(parseExampleRef)
const exec = vi.fn(async () => {
throw new Error("fatal: No such remote 'origin'")
})
getSshGitProviderMock.mockReturnValue({ exec })
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
expect(exec).toHaveBeenCalledTimes(1)
vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1)
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
expect(exec).toHaveBeenCalledTimes(2)
})
it('keeps re-asking an SSH repo whose probe died with its transport', async () => {
const cache = createRemoteRefProbeCache(parseExampleRef)
const exec = vi
.fn()
.mockRejectedValueOnce(new Error('relay request failed: connection closed'))
.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' })
getSshGitProviderMock.mockReturnValue({ exec })
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull()
await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' })
expect(exec).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not cache a probe killed on its deadline as a definitive miss', async () => {
+3 -19
View File
@@ -1,7 +1,5 @@
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe'
import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
/**
@@ -86,10 +84,7 @@ export function createRemoteRefProbeCache<Ref>(
},
remoteName
)
// Why: null is the SSH runtime being disconnected, not an answer about the
// remote — and it costs no `git`, so there is nothing here to spare. It is
// deliberately the one negative with no TTL floor: flooring it would make a
// reconnected host wait the interval out for a probe it could serve now.
// Why: null is an SSH repo this process cannot read, not an answer about the remote.
if (stdout === null) {
return null
}
@@ -97,16 +92,10 @@ export function createRemoteRefProbeCache<Ref>(
publish(result)
return result
} catch (error) {
// Why: a probe killed on its deadline says nothing about the remote, and an
// SSH failure is usually a reconnect or tunnel state rather than an answer.
// Only "no such remote" is the repo itself saying it is not this provider's
// — anything else cached would poison it, on SSH for the generation's life.
// Why: a probe killed on its deadline says nothing about the remote.
if (isTransientGitProbeError(error)) {
return null
}
if (connectionId && !isStableMissingGitRemoteError(error)) {
return null
}
publish(null)
return null
}
@@ -114,12 +103,7 @@ export function createRemoteRefProbeCache<Ref>(
return {
async get(repoPath, remoteName, connectionId, localGitOptions = {}) {
// Why: a reconnect retires the connection an answer came from, and with it
// the probe still running on it — stamping the generation stops a caller on
// the new connection from adopting either.
const runtimeKey = connectionId
? `${connectionId}:${getSshGitProviderGeneration(connectionId)}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
const runtimeKey = connectionId || `local:${localGitOptions.wslDistro ?? 'host'}`
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}`
const cached = repoRefCache.get(cacheKey)
if (cached) {
+1 -32
View File
@@ -1,16 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getSshGitProviderMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
getSshGitProviderMock: vi.fn(),
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable',
sshGitProviderMissingError: () => new Error('SSH Git provider unavailable')
}))
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
@@ -23,7 +16,6 @@ import {
describe('remote URL probe', () => {
beforeEach(() => {
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockReset()
})
@@ -48,27 +40,6 @@ describe('remote URL probe', () => {
})
})
it('bounds the SSH remote read with the same deadline as the local one', async () => {
const exec = vi.fn(
async (_args: string[], _cwd: string, _options?: { signal?: AbortSignal }) => ({
stdout: 'git@github.com:acme/orca.git\n'
})
)
getSshGitProviderMock.mockReturnValue({ exec })
await expect(
readRemoteUrl({ repoPath: '/repo', connectionId: 'ssh-1' }, 'origin')
).resolves.toContain('github.com')
const [args, cwd, options] = exec.mock.calls[0]
expect(args).toEqual(['remote', 'get-url', 'origin'])
expect(cwd).toBe('/repo')
// The relay bounds each phase separately and resets on every frame, so only a
// signal spans the whole round trip.
expect(options?.signal).toBeInstanceOf(AbortSignal)
expect(options?.signal?.aborted).toBe(false)
})
it('reports a probe cut off by its deadline as unavailable, not as an answer', async () => {
const aborted = new Error('Request was cancelled')
aborted.name = 'AbortError'
@@ -93,8 +64,6 @@ describe('remote URL probe', () => {
})
it('does not turn an SSH target into a false readable result', async () => {
getSshGitProviderMock.mockReturnValue({ exec: vi.fn() })
await expect(
assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' })
).rejects.toThrow(SshHostNotServedError)
+3 -13
View File
@@ -1,4 +1,3 @@
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { gitExecFileAsync } from './runner'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
@@ -13,9 +12,7 @@ import { getConnectionExecutionHostId } from '../../shared/execution-host'
* wedged host — a dead network mount or stalled WSL interop. Unbounded, the call
* never returns and every caller above it hangs with it. Passing a timeout is
* also what arms the runner's kill path; Node's own waits forever on a child
* that ignores signals. The SSH branch spends the same budget as one deadline
* over the whole round trip: the relay's own bounds are per-phase and restart on
* every frame, so a relay dribbling output outlives them.
* that ignores signals.
*/
export const REMOTE_URL_PROBE_TIMEOUT_MS = 30_000
@@ -26,20 +23,13 @@ export type RemoteUrlProbeContext = {
admissionTier?: GitAdmissionTier
}
/** Reads a remote URL, or null when the repo's SSH runtime is not connected. */
/** Reads a remote URL, or null for an SSH repo, which this process does not serve. */
export async function readRemoteUrl(
context: RemoteUrlProbeContext,
remoteName: string
): Promise<string | null> {
if (context.connectionId) {
const provider = getSshGitProvider(context.connectionId)
if (!provider) {
return null
}
const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath, {
signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS)
})
return stdout
return null
}
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: context.repoPath,
+1 -1
View File
@@ -110,7 +110,7 @@ export async function getRecentDriftSubjects(
}
/** Parse `git remote` stdout into a remote count. */
export function parseRemoteCount(stdout: string): number {
function parseRemoteCount(stdout: string): number {
return stdout.split('\n').filter((line) => line.trim().length > 0).length
}
@@ -195,6 +195,7 @@ describe('worktree removal and the Node file pool', () => {
sentinels.push(sentinel)
}
const checkoutDeleted = (): boolean => sentinels.every((path) => statSync(path).nlink === 1)
const deletionStarted = (): boolean => sentinels.some((path) => statSync(path).nlink === 1)
const probePath = join(repoPath, 'seed.txt')
let removalSettled = false
@@ -207,6 +208,7 @@ describe('worktree removal and the Node file pool', () => {
let maxQueued = 0
let maxStatMs = 0
let statCount = 0
let statsDuringDeletion = 0
const sampler = setInterval(() => {
maxQueued = Math.max(maxQueued, queuedFsRequests())
}, 1)
@@ -214,9 +216,13 @@ describe('worktree removal and the Node file pool', () => {
while (!removalSettled || !checkoutDeleted()) {
expect(performance.now() - startedAt).toBeLessThan(60_000)
maxQueued = Math.max(maxQueued, queuedFsRequests())
const deletionInProgress = deletionStarted() && !checkoutDeleted()
const statStartedAt = performance.now()
await stat(probePath)
maxStatMs = Math.max(maxStatMs, performance.now() - statStartedAt)
if (deletionInProgress && !checkoutDeleted()) {
statsDuringDeletion += 1
}
statCount += 1
}
} finally {
@@ -226,10 +232,10 @@ describe('worktree removal and the Node file pool', () => {
await removal
console.log(
`[pool] files=${POOL_FIXTURE_FILES} delete=${deletionMs.toFixed(0)}ms stats=${statCount} maxStat=${maxStatMs.toFixed(1)}ms maxQueuedFsRequests=${maxQueued}`
`[pool] files=${POOL_FIXTURE_FILES} delete=${deletionMs.toFixed(0)}ms stats=${statCount} statsDuringDeletion=${statsDuringDeletion} maxStat=${maxStatMs.toFixed(1)}ms maxQueuedFsRequests=${maxQueued}`
)
expect(existsSync(worktreePath)).toBe(false)
expect(maxQueued).toBeLessThan(32)
expect(maxStatMs).toBeLessThan(deletionMs / 4)
expect(statsDuringDeletion).toBeGreaterThan(0)
}, 120_000)
})
@@ -6,10 +6,6 @@ vi.mock('../git/runner', () => ({
stderr: ''
}))
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSSHGitProvider: vi.fn(() => undefined),
getSSHGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => '')
}))
+2 -49
View File
@@ -2,20 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation'
import { _resetGiteaRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
@@ -27,7 +21,6 @@ describe('Gitea pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://git.example.com/code/team/repo.git\n',
stderr: ''
@@ -102,46 +95,6 @@ describe('Gitea pull request creation', () => {
expect(fetchMock).toHaveBeenCalledOnce()
})
it('resolves Gitea remotes through the SSH git provider', async () => {
const remoteGit = {
exec: vi.fn(async () => ({
stdout: 'git@git.example.com:code/team/repo.git\n',
stderr: ''
}))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
globalThis.fetch = vi.fn(async () =>
Response.json({
number: 14,
title: 'Remote Gitea create',
state: 'open',
html_url: 'https://git.example.com/code/team/repo/pulls/14',
updated_at: '2026-06-01T00:00:00Z',
mergeable: true
})
) as never
await expect(
createGiteaPullRequest(
'/remote/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/gitea',
title: 'Remote Gitea create'
},
'ssh:ssh-1'
)
).resolves.toMatchObject({
ok: true,
number: 14
})
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('classifies validation failures from the REST API', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ message: 'Validation failed' }, { status: 422 })
+3 -29
View File
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
@@ -13,23 +12,17 @@ import {
_getGiteaRepoRefCacheSize,
_resetGiteaRepoRefCache,
getGiteaRepoRef,
getGiteaRepoRefForRemote,
parseGiteaRepoRef
} from './repository-ref'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
describe('Gitea repository ref parsing', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetGiteaRepoRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
afterEach(() => {})
it('parses HTTPS remotes and derives the API base URL', () => {
expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({
@@ -139,23 +132,4 @@ describe('Gitea repository ref parsing', () => {
expect(_getGiteaRepoRefCacheSize()).toBe(512)
})
it('resolves repository refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({
stdout: 'git@gitea.example.test:remote/project.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({
host: 'gitea.example.test',
owner: 'remote',
repo: 'project'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', {
signal: expect.any(AbortSignal)
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})
+2 -80
View File
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFile } from 'node:fs/promises'
const {
ghExecFileAsyncMock,
@@ -8,8 +7,7 @@ const {
getEnterpriseGitHubRepoSlugMock,
extractExecErrorMock,
acquireMock,
releaseMock,
getSshFilesystemProviderMock
releaseMock
} = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
@@ -23,12 +21,7 @@ const {
}
}),
acquireMock: vi.fn(),
releaseMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
@@ -88,7 +81,6 @@ describe('createGitHubPullRequest', () => {
extractExecErrorMock.mockClear()
acquireMock.mockReset()
releaseMock.mockReset()
getSshFilesystemProviderMock.mockReset()
acquireMock.mockResolvedValue(undefined)
})
@@ -354,76 +346,6 @@ describe('createGitHubPullRequest', () => {
expect(options).not.toHaveProperty('cwd')
})
it.each([
['.github/pull_request_template.md', ['.github/pull_request_template.md']],
[
'.github/PULL_REQUEST_TEMPLATE.md',
['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md']
],
[
'docs/PULL_REQUEST_TEMPLATE.md',
[
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'pull_request_template.md',
'PULL_REQUEST_TEMPLATE.md',
'docs/pull_request_template.md',
'docs/PULL_REQUEST_TEMPLATE.md'
]
]
] as [string, string[]][])(
'reads PR templates from the SSH filesystem provider at %s',
async (relativeTemplatePath, expectedRelativeLookups) => {
const templateBody = `Remote template body from ${relativeTemplatePath}`
const readRemoteFile = vi.fn(async (path: string) => {
if (path === `/remote/repo-root/${relativeTemplatePath}`) {
return {
content: templateBody,
isBinary: false
}
}
throw new Error('missing template')
})
getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
const writtenBodies: string[] = []
ghExecFileAsyncMock.mockImplementationOnce(async (args: string[]) => {
const bodyPath = args[args.indexOf('--body-file') + 1]
writtenBodies.push(await readFile(bodyPath, 'utf8'))
return {
stdout: JSON.stringify({
number: 46,
url: 'https://github.com/acme/widgets/pull/46'
})
}
})
await expect(
createGitHubPullRequest(
'/remote/repo-root',
{
provider: 'github',
base: 'main',
head: 'feature/ssh-template',
title: 'SSH Template PR',
body: '',
useTemplate: true
},
'ssh:ssh-1'
)
).resolves.toEqual({
ok: true,
number: 46,
url: 'https://github.com/acme/widgets/pull/46'
})
expect(readRemoteFile.mock.calls.map(([path]) => path)).toEqual(
expectedRelativeLookups.map((relativeLookup) => `/remote/repo-root/${relativeLookup}`)
)
expect(writtenBodies).toEqual([templateBody])
}
)
it('falls back to parsing the PR URL for older gh output', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -84,11 +84,6 @@ vi.mock('./github-enterprise-repository', () => ({
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: vi.fn(() => 0),
getSshGitProvider: vi.fn()
}))
vi.mock('./conflict-summary', () => ({
getPRConflictSummary: vi.fn()
}))
@@ -28,7 +28,6 @@ vi.mock('./gh-utils', () => ({
vi.mock('./client/lookup/branch-lookup-resolution', () => ({
resolvePRForBranchOutcome: mocks.resolve
}))
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 }))
import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
const outcome: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 }
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
-1
View File
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -34,7 +33,6 @@ const {
ghExecFileAsyncMock,
getOwnerRepoMock,
gitExecFileAsyncMock,
getSshGitProviderMock,
resolvePRRepositoryCandidatesMock
} = clientMocks
@@ -123,7 +121,6 @@ describe('getPRForBranch SSH execution boundary', () => {
})
it('does not rev-parse HEAD locally when the SSH provider is unregistered', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
mockMergedBranchPRLookup()
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
@@ -171,7 +168,6 @@ describe('getPRForBranch SSH execution boundary', () => {
})
it('does not read tracked upstreams locally when the SSH provider is unregistered', async () => {
getSshGitProviderMock.mockReturnValue(undefined)
mockUpstreamOnlyPRLookup()
gitExecFileAsyncMock.mockResolvedValue({
stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`,
@@ -186,9 +182,6 @@ describe('getPRForBranch SSH execution boundary', () => {
})
it('reports an upstream error when the SSH HEAD probe loses its provider mid-flight', async () => {
getSshGitProviderMock.mockReturnValue({
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
})
mockMergedBranchPRLookup()
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
@@ -198,9 +191,6 @@ describe('getPRForBranch SSH execution boundary', () => {
})
it('reports an upstream error when the SSH upstream probe loses its provider mid-flight', async () => {
getSshGitProviderMock.mockReturnValue({
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
})
mockUpstreamOnlyPRLookup()
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
-1
View File
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
-1
View File
@@ -41,7 +41,6 @@ export function resetPRForBranchMocks(mocks: GitHubClientMocks): void {
mocks.noteRateLimitSpendMock.mockReset()
mocks.ghRepoExecOptionsMock.mockClear()
mocks.githubRepoContextMock.mockClear()
mocks.getSshGitProviderMock.mockReset()
mocks.readLocalGitConfigSignatureMock.mockReset()
mocks.readLocalGitConfigSignatureMock.mockResolvedValue(undefined)
mocks.acquireMock.mockReset()
+2 -16
View File
@@ -23,7 +23,6 @@ export type GitHubClientMocks = {
noteRateLimitSpendMock: Mock
ghRepoExecOptionsMock: Mock<typeof ghRepoExecOptions>
githubRepoContextMock: Mock<typeof githubRepoContext>
getSshGitProviderMock: Mock
readLocalGitConfigSignatureMock: Mock
acquireMock: Mock
releaseMock: Mock
@@ -62,7 +61,6 @@ export function createGitHubClientMocks(): GitHubClientMocks {
...localGitOptions
})
),
getSshGitProviderMock: vi.fn(),
readLocalGitConfigSignatureMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
@@ -131,22 +129,10 @@ export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMo
}
}
export type SshGitDispatchModuleMock = {
getSshGitProviderGeneration: Mock
getSshGitProvider: Mock
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string
export type LocalGitConfigSignatureModuleMock = {
readLocalGitConfigSignature: Mock
}
export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock {
return {
getSshGitProviderGeneration: vi.fn(() => 0),
getSshGitProvider: mocks.getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
}
}
export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock }
export function localGitConfigSignatureModuleMock(
mocks: GitHubClientMocks
): LocalGitConfigSignatureModuleMock {
@@ -10,7 +10,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
vi.mock('./local-git-config-signature', () =>
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
)
@@ -1,5 +1,4 @@
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch'
import { githubReadExecutionScope } from '../../github-read-execution-scope'
import type { PRCheckDetail } from '../../../../shared/github/check-types'
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
@@ -253,7 +252,6 @@ export function getPRChecks(
prRepo ?? null,
Boolean(options?.noCache),
connectionId ?? null,
connectionId ? getSshGitProviderGeneration(connectionId) : null,
githubReadExecutionScope(localGitOptions)
])
return runCoalescedProbe(checksReads, key, () =>
@@ -1,7 +1,5 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch'
import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths'
import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
import {
getHostedReviewLocalGitOptions,
@@ -60,21 +58,11 @@ export async function readPullRequestTemplate(
'docs/pull_request_template.md',
'docs/PULL_REQUEST_TEMPLATE.md'
]
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
if (connectionId && !remoteProvider) {
if (connectionId) {
return ''
}
for (const relativeCandidate of relativeCandidates) {
try {
if (remoteProvider) {
const result = await remoteProvider.readFile(
joinWorktreeRelativePath(repoPath, relativeCandidate)
)
if (result.isBinary) {
continue
}
return result.content
}
return await readFile(join(repoPath, relativeCandidate), 'utf8')
} catch {
// Try the next conventional PR template path.
@@ -4,7 +4,6 @@ import {
scopeGeneration
} from '../../../source-control/hosted-review-scope-generations'
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch'
import { githubReadExecutionScope } from '../../github-read-execution-scope'
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
@@ -35,7 +34,6 @@ export async function getPRForBranchOutcome(
const key = JSON.stringify([
executionScope,
connectionId ? getSshGitProviderGeneration(connectionId) : null,
repoPath,
scopeGeneration(hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId }))),
branchName,
@@ -35,7 +35,6 @@ const {
noteRepositoryRateLimitSpendMock,
ghRepoExecOptionsMock,
githubRepoContextMock,
getSshGitProviderMock,
readLocalGitConfigSignatureMock,
acquireMock,
releaseMock
@@ -67,7 +66,6 @@ const {
connectionId: connectionId ?? null,
...localGitOptions
})),
getSshGitProviderMock: vi.fn(),
readLocalGitConfigSignatureMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
@@ -107,10 +105,6 @@ vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('./local-git-config-signature', () => ({
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
}))
@@ -221,7 +215,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open
noteRateLimitSpendMock.mockReset()
ghRepoExecOptionsMock.mockClear()
githubRepoContextMock.mockClear()
getSshGitProviderMock.mockReset()
readLocalGitConfigSignatureMock.mockReset()
readLocalGitConfigSignatureMock.mockResolvedValue(undefined)
acquireMock.mockReset()
+3 -62
View File
@@ -3,24 +3,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted(
() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
getSshGitProviderMock: vi.fn()
})
)
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
ghExecFileAsync: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
getSshGitProvider: getSshGitProviderMock
}))
import { _resetRemoteNameListingCache } from '../git/remote-name-listing'
import {
_getOwnerRepoCacheSize,
@@ -73,9 +64,6 @@ function gitRemoteListCalls(): unknown[][] {
describe('github owner/repo resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
getSshGitProviderGenerationMock.mockReset()
getSshGitProviderGenerationMock.mockReturnValue(0)
getSshGitProviderMock.mockReset()
_resetOwnerRepoCache()
_resetRemoteNameListingCache()
__resetLocalGitConfigSignatureCacheForTests()
@@ -200,52 +188,6 @@ describe('github owner/repo resolution', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('resolves SSH repo remotes through the registered SSH git provider', async () => {
const sshProvider = {
exec: vi.fn(async (args: string[]) => {
if (args[0] === 'remote' && args[1] !== 'get-url') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[2] === 'upstream') {
throw new Error("fatal: No such remote 'upstream'")
}
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
})
}
getSshGitProviderMock.mockReturnValue(sshProvider)
await expect(getOwnerRepo('/home/user/orca', 'openclaw-2')).resolves.toEqual({
owner: 'stablyai',
repo: 'orca'
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(getSshGitProviderMock).toHaveBeenCalledWith('openclaw-2')
expect(sshProvider.exec).toHaveBeenCalledWith(
['remote', 'get-url', 'origin'],
'/home/user/orca',
{
signal: expect.any(AbortSignal)
}
)
})
it('keeps local and SSH owner/repo cache entries separate for the same path', async () => {
const sshProvider = {
exec: vi.fn(async (args: string[]) => {
if (args[0] === 'remote' && args[1] !== 'get-url') {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: 'git@github.com:remote/orca.git\n', stderr: '' }
})
}
mockGitRemoteCommands({ origin: 'git@github.com:local/orca.git\n' })
getSshGitProviderMock.mockReturnValue(sshProvider)
await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'local', repo: 'orca' })
await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' })
})
it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => {
gitExecFileAsyncMock.mockImplementation(
async (args: string[], options: { wslDistro?: string } = {}) => {
@@ -747,7 +689,6 @@ describe('github owner/repo resolution', () => {
describe('resolveIssueSource', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
_resetOwnerRepoCache()
_resetRemoteNameListingCache()
})
@@ -1,5 +1,4 @@
import type { LocalGitExecOptions } from './github-repository-identity'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
export function githubApiRepositoryProbeCacheKey(
repoPath: string,
@@ -9,7 +8,7 @@ export function githubApiRepositoryProbeCacheKey(
requireVerifiedSshProbe: boolean
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
? `ssh:${connectionId}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return `${runtimeKey}\0${repoPath}\0${remoteName}\0${requireVerifiedSshProbe ? 'verified' : 'tolerant'}`
}
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SshGitDispatch from '../providers/ssh-git-dispatch'
import type * as GitHubEnterpriseRepository from './github-enterprise-repository'
import type * as GhUtils from './gh-utils'
@@ -7,23 +6,16 @@ const {
getEnterpriseGitHubRepoSlugMock,
getOwnerRepoMock,
getOwnerRepoForRemoteMock,
getSshGitProviderGenerationMock,
isGitHubHostAuthenticatedMock,
shouldProbeGitRemoteMock
} = vi.hoisted(() => ({
getEnterpriseGitHubRepoSlugMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getOwnerRepoForRemoteMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
isGitHubHostAuthenticatedMock: vi.fn(),
shouldProbeGitRemoteMock: vi.fn(async () => true)
}))
vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({
...(await importOriginal<typeof SshGitDispatch>()),
getSshGitProviderGeneration: getSshGitProviderGenerationMock
}))
vi.mock('./gh-utils', async (importOriginal) => ({
...(await importOriginal<typeof GhUtils>()),
getOwnerRepo: getOwnerRepoMock,
@@ -57,7 +49,6 @@ beforeEach(() => {
getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null)
getOwnerRepoMock.mockReset().mockResolvedValue(null)
getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null)
getSshGitProviderGenerationMock.mockReset().mockReturnValue(0)
isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false)
shouldProbeGitRemoteMock.mockReset().mockResolvedValue(true)
})
@@ -204,23 +195,6 @@ describe('resolveGitHubRepoExecution', () => {
})
describe('origin repository cache', () => {
it('isolates Enterprise identity across SSH provider generations', async () => {
const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }
const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' }
getEnterpriseGitHubRepoSlugMock
.mockResolvedValueOnce(beforeReconnect)
.mockResolvedValueOnce(afterReconnect)
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
beforeReconnect
)
getSshGitProviderGenerationMock.mockReturnValue(1)
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
afterReconnect
)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
})
it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => {
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined)
@@ -280,83 +254,6 @@ describe('origin repository cache', () => {
await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull()
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
})
it('does not reuse a cached answer after the SSH provider reconnects', async () => {
const oldRepository = {
owner: 'old-owner',
repo: 'widgets',
host: 'github.acme-corp.com'
}
const newRepository = {
owner: 'new-owner',
repo: 'widgets',
host: 'github.acme-corp.com'
}
getSshGitProviderGenerationMock.mockReturnValue(1)
getEnterpriseGitHubRepoSlugMock
.mockResolvedValueOnce(oldRepository)
.mockResolvedValueOnce(newRepository)
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository)
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
getSshGitProviderGenerationMock.mockReturnValue(2)
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
})
it('does not reuse a cached negative after the SSH provider reconnects', async () => {
const repository = {
owner: 'acme',
repo: 'widgets',
host: 'github.acme-corp.com'
}
getSshGitProviderGenerationMock.mockReturnValue(1)
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(repository)
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull()
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull()
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
getSshGitProviderGenerationMock.mockReturnValue(2)
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(repository)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
})
it('isolates an in-flight probe from an older SSH provider generation', async () => {
const oldRepository = {
owner: 'old-owner',
repo: 'widgets',
host: 'github.acme-corp.com'
}
const newRepository = {
owner: 'new-owner',
repo: 'widgets',
host: 'github.acme-corp.com'
}
let resolveOldProbe: (repository: typeof oldRepository) => void = () => {}
const oldProbeResult = new Promise<typeof oldRepository>((resolve) => {
resolveOldProbe = resolve
})
getEnterpriseGitHubRepoSlugMock
.mockImplementationOnce(() => oldProbeResult)
.mockResolvedValueOnce(newRepository)
getSshGitProviderGenerationMock.mockReturnValue(1)
const oldProbe = getOriginGitHubApiRepository('/repo', 'ssh-1')
await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1))
getSshGitProviderGenerationMock.mockReturnValue(2)
const newProbe = getOriginGitHubApiRepository('/repo', 'ssh-1')
await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2))
await expect(newProbe).resolves.toEqual(newRepository)
resolveOldProbe(oldRepository)
await expect(oldProbe).resolves.toEqual(oldRepository)
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository)
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
})
})
describe('skip missing upstream remote probes', () => {
@@ -27,7 +27,6 @@ import {
isGitHubHostAuthenticatedForGlobalCli
} from './github-enterprise-repository'
import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
function mockOriginRemote(url: string): void {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
@@ -437,74 +436,6 @@ describe('isGitHubHostAuthenticated', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not reuse a cached answer after the SSH provider reconnects', async () => {
const connectionId = 'ssh-reconnected-auth-cache'
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout:
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)',
stderr: ''
})
.mockResolvedValueOnce({
stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)',
stderr: ''
})
registerSshGitProvider(connectionId, {} as never)
try {
await expect(
isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
).resolves.toBe(true)
await expect(
isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
).resolves.toBe(true)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
registerSshGitProvider(connectionId, {} as never)
await expect(
isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
).resolves.toBe(false)
} finally {
unregisterSshGitProvider(connectionId)
}
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not join an in-flight auth probe from an older SSH provider generation', async () => {
const connectionId = 'ssh-reconnected-auth-in-flight'
let finishOldProbe: (() => void) | undefined
ghExecFileAsyncMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOldProbe = () =>
resolve({
stdout:
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)',
stderr: ''
})
})
)
.mockResolvedValueOnce({
stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)',
stderr: ''
})
registerSshGitProvider(connectionId, {} as never)
try {
const oldProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
registerSshGitProvider(connectionId, {} as never)
const newProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId)
await expect(newProbe).resolves.toBe(false)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
finishOldProbe?.()
await expect(oldProbe).resolves.toBe(true)
} finally {
unregisterSshGitProvider(connectionId)
}
})
// The probe answers differ by runtime: a local repo gets a cwd, a
// connection-backed repo does not. Sharing one entry attributes whichever
// resolved first to both hosts.

Some files were not shown because too many files have changed in this diff Show More